visualproof 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +99 -2
- package/dist/assets/fonts/JuliaMono-Regular.ttf +0 -0
- package/dist/assets/fonts/JuliaMono-RegularItalic.ttf +0 -0
- package/dist/assets/fonts/JuliaMono-SemiBold.ttf +0 -0
- package/dist/assets/fonts/LICENSE-JuliaMono.txt +93 -0
- package/dist/assets/latex/visualproof.sty +308 -0
- package/dist/extractor/Extract.lean +1180 -0
- package/dist/extractor/Usage.lean +900 -0
- package/dist/mcp/visualproof-mcp.mjs +476 -0
- package/dist/server/access.js +95 -0
- package/dist/server/agent.js +3235 -0
- package/dist/server/agentdocs.js +35 -0
- package/dist/server/agentlibrary.js +202 -0
- package/dist/server/agentnotes.js +701 -0
- package/dist/server/agentviews.js +703 -0
- package/dist/server/askfollow.js +286 -0
- package/dist/server/boardspots.js +159 -0
- package/dist/server/dirs.js +175 -0
- package/dist/server/doctor.js +186 -0
- package/dist/server/events.js +198 -0
- package/dist/server/export.js +420 -0
- package/dist/server/extract.js +204 -0
- package/dist/server/fetchurl.js +321 -0
- package/dist/server/figures.js +158 -0
- package/dist/server/history.js +741 -0
- package/dist/server/historyapi.js +304 -0
- package/dist/server/hover.js +595 -0
- package/dist/server/identity.js +114 -0
- package/dist/server/invites.js +241 -0
- package/dist/server/layoutthread.js +51 -0
- package/dist/server/library.js +600 -0
- package/dist/server/libraryroute.js +211 -0
- package/dist/server/live.js +1101 -0
- package/dist/server/lostfound.js +199 -0
- package/dist/server/main.js +1394 -0
- package/dist/server/mcptools.js +497 -0
- package/dist/server/pastdoc.js +73 -0
- package/dist/server/pipeline.js +156 -0
- package/dist/server/poppler.js +251 -0
- package/dist/server/presence.js +252 -0
- package/dist/server/project.js +222 -0
- package/dist/server/prompt.js +53 -0
- package/dist/server/readonly.js +152 -0
- package/dist/server/readonlygate.js +123 -0
- package/dist/server/renames.js +268 -0
- package/dist/server/review.js +334 -0
- package/dist/server/reviewapi.js +188 -0
- package/dist/server/snapshot.js +49 -0
- package/dist/server/sourcestore.js +473 -0
- package/dist/server/status.js +360 -0
- package/dist/server/talk.js +476 -0
- package/dist/server/tokens.js +221 -0
- package/dist/server/usage.js +501 -0
- package/dist/server/versions.js +82 -0
- package/dist/server/view.js +157 -0
- package/dist/server/views.js +1167 -0
- package/dist/shared/askfollow.js +46 -0
- package/dist/shared/board.js +332 -0
- package/dist/shared/build.js +2 -0
- package/dist/shared/collab.js +148 -0
- package/dist/shared/comment.js +285 -0
- package/dist/shared/context.js +157 -0
- package/dist/shared/doc/authors.js +98 -0
- package/dist/shared/doc/build.js +358 -0
- package/dist/shared/doc/latex.js +455 -0
- package/dist/shared/doc/markdown.js +159 -0
- package/dist/shared/doc/model.js +37 -0
- package/dist/shared/doc/order.js +107 -0
- package/dist/shared/doc/proof.js +272 -0
- package/dist/shared/doc/sections.js +93 -0
- package/dist/shared/doc/settings.js +75 -0
- package/dist/shared/edgecodec.js +102 -0
- package/dist/shared/editor.js +64 -0
- package/dist/shared/excerpt.js +87 -0
- package/dist/shared/graph.js +16 -0
- package/dist/shared/items.js +92 -0
- package/dist/shared/keys.js +203 -0
- package/dist/shared/layout.js +926 -0
- package/dist/shared/lexer.js +130 -0
- package/dist/shared/library.js +711 -0
- package/dist/shared/lostfound.js +150 -0
- package/dist/shared/moves.js +243 -0
- package/dist/shared/notecard.js +295 -0
- package/dist/shared/noteplace.js +505 -0
- package/dist/shared/palette.js +46 -0
- package/dist/shared/pastgraph.js +95 -0
- package/dist/shared/places.js +80 -0
- package/dist/shared/projectnotes.js +244 -0
- package/dist/shared/query.js +810 -0
- package/dist/shared/review.js +642 -0
- package/dist/shared/reviewqueue.js +333 -0
- package/dist/shared/ring.js +366 -0
- package/dist/shared/scope.js +255 -0
- package/dist/shared/scopeedit.js +81 -0
- package/dist/shared/search.js +191 -0
- package/dist/shared/separate.js +178 -0
- package/dist/shared/sharesnap.js +280 -0
- package/dist/shared/sticky.js +501 -0
- package/dist/shared/timeline.js +73 -0
- package/dist/shared/titles.js +298 -0
- package/dist/shared/viewgraph.js +139 -0
- package/dist/shared/views.js +859 -0
- package/dist/web/bundle/app.js +48733 -0
- package/dist/web/bundle/edges.js +409 -0
- package/dist/web/bundle/layout.js +1010 -0
- package/dist/web/bundle/pdfworker.js +48322 -0
- package/dist/web/index.html +239 -0
- package/dist/web/src/motion.js +430 -0
- package/dist/web/src/past.js +224 -0
- package/dist/web/src/scene.js +787 -0
- package/dist/web/style.css +2312 -0
- package/docs/agent-api/README.md +91 -0
- package/docs/agent-api/advice.md +108 -0
- package/docs/agent-api/export.md +120 -0
- package/docs/agent-api/following.md +140 -0
- package/docs/agent-api/history.md +142 -0
- package/docs/agent-api/library.md +65 -0
- package/docs/agent-api/mcp.md +104 -0
- package/docs/agent-api/notes.md +93 -0
- package/docs/agent-api/reading.md +166 -0
- package/docs/agent-api/requests.md +94 -0
- package/docs/agent-api/talking.md +100 -0
- package/docs/agent-api/tcb.md +135 -0
- package/docs/agent-api/views.md +124 -0
- package/docs/agent-api/words.md +56 -0
- package/package.json +39 -4
- package/test/toolchains.json +5 -0
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
// Hover information for the source window, as VS Code shows it: Lean's own language server,
|
|
2
|
+
// asked "what is at this position".
|
|
3
|
+
//
|
|
4
|
+
// The server is started from the project's `lean-server/` folder of the cache, not from the project: Lean's file
|
|
5
|
+
// worker runs `lake setup-file` — which builds out-of-date imports — only when it finds a
|
|
6
|
+
// lakefile in the directory it was started from. Started elsewhere with the project's LEAN_PATH,
|
|
7
|
+
// it reads the `.olean` files the owner's builds left and writes nothing (SPEC.md: "No .lake
|
|
8
|
+
// conflict"). It elaborates the file it is asked about once, then answers hovers in milliseconds.
|
|
9
|
+
// At most two files are kept open; the server stops after ten idle minutes.
|
|
10
|
+
//
|
|
11
|
+
// A name in a hover card opens a card of its own: what Lean says about a declaration by name is
|
|
12
|
+
// asked in a scratch file of VisualProof's (in the project's cache, never in the project) that
|
|
13
|
+
// imports the module on show and `#check`s the name. The agents' API asks there too, and also has
|
|
14
|
+
// Lean `#print` a name: the elaborated definition, which Lean says as a message about the command
|
|
15
|
+
// (a diagnostic), not as a hover.
|
|
16
|
+
//
|
|
17
|
+
// Lean runs each open file in a worker process of its own, which can die (a worker on a large
|
|
18
|
+
// project holds gigabytes: 4.1 GB for the showcase's main theorem's module; and the kernel's OOM killer picks the
|
|
19
|
+
// largest). Lean then says the file's progress
|
|
20
|
+
// is a fatal error, and starts the worker again only when the document changes: until then every
|
|
21
|
+
// question about it would wait forever. So a crashed document is closed and opened again here, as a
|
|
22
|
+
// new version, and its questions asked anew; one that keeps crashing is given up on for a while,
|
|
23
|
+
// with why; a question Lean does not answer in REQUEST_MS is answered that it did not; and what
|
|
24
|
+
// Lean is doing is part of /api/status (`state`).
|
|
25
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
26
|
+
import { existsSync, mkdirSync } from "node:fs";
|
|
27
|
+
import { join } from "node:path";
|
|
28
|
+
import { pathToFileURL } from "node:url";
|
|
29
|
+
/** A Lean name, dotted, and nothing else: no spaces, no line breaks, no commands. */
|
|
30
|
+
export function isDeclName(s) {
|
|
31
|
+
const part = "(?:[\\p{L}_][\\p{L}\\p{N}_'!?\u2080-\u2089\u2090-\u209c]*|«[^»\\n\\r]*»|[0-9]+)";
|
|
32
|
+
return s.length <= 2000 && new RegExp(`^${part}(?:\\.${part})*$`, "u").test(s);
|
|
33
|
+
}
|
|
34
|
+
const SEVERITY = ["error", "error", "warning", "information", "hint"];
|
|
35
|
+
const KEEP_OPEN = 2;
|
|
36
|
+
const IDLE_MS = 10 * 60 * 1000;
|
|
37
|
+
/** the longest a question waits for Lean's answer before it is answered that there was none */
|
|
38
|
+
const REQUEST_MS = 5 * 60 * 1000;
|
|
39
|
+
/** a document still being elaborated that Lean has said nothing about for this long has stopped,
|
|
40
|
+
* whatever Lean says: it is opened again, as a crashed one (loading a large project's imports can
|
|
41
|
+
* take minutes on a machine short of memory, in which Lean says nothing; on an idle one the
|
|
42
|
+
* showcase's main theorem's module answers in 2 s) */
|
|
43
|
+
const STALL_MS = 10 * 60 * 1000;
|
|
44
|
+
/** crashes in a row (the document never elaborated to its end between them) after which a document
|
|
45
|
+
* is not opened again until GIVE_UP_MS have passed */
|
|
46
|
+
const MAX_CRASHES = 2;
|
|
47
|
+
const GIVE_UP_MS = 5 * 60 * 1000;
|
|
48
|
+
/** LeanFileProgressKind.fatalError, as Lean sends it: the worker for the document crashed */
|
|
49
|
+
const FATAL = 2;
|
|
50
|
+
/** questions waiting for their turn in the scratch file, at most */
|
|
51
|
+
const MAX_QUEUED = 16;
|
|
52
|
+
export class LeanHover {
|
|
53
|
+
project;
|
|
54
|
+
dir;
|
|
55
|
+
proc = null;
|
|
56
|
+
starting = null;
|
|
57
|
+
buf = Buffer.alloc(0);
|
|
58
|
+
nextId = 0;
|
|
59
|
+
waiting = new Map();
|
|
60
|
+
/** crashes in a row per document (a uri), reset once it elaborates to its end */
|
|
61
|
+
crashes = new Map();
|
|
62
|
+
/** documents not opened again for now, with why and since when */
|
|
63
|
+
givenUp = new Map();
|
|
64
|
+
/** documents opened again after a crash or a stall, since the start */
|
|
65
|
+
restarts = 0;
|
|
66
|
+
/** frames of Lean's output that could not be read, and when that was last said */
|
|
67
|
+
garbled = { count: 0, said: 0 };
|
|
68
|
+
docs = new Map();
|
|
69
|
+
/** hover requests in flight, by uri:line:character, so a retry joins the one already asked */
|
|
70
|
+
asked = new Map();
|
|
71
|
+
idle = null;
|
|
72
|
+
env = null;
|
|
73
|
+
/** why Lean cannot be asked, once found out */
|
|
74
|
+
failure = null;
|
|
75
|
+
/** the scratch file names are described in, and the describing in progress: one at a time,
|
|
76
|
+
* since each changes the file */
|
|
77
|
+
scratch = null;
|
|
78
|
+
describing = Promise.resolve();
|
|
79
|
+
queued = 0;
|
|
80
|
+
/** the scratch file's last version: one count across Lean's restarts and the file's reopenings,
|
|
81
|
+
* so that nothing Lean said about an earlier one is taken for the present one */
|
|
82
|
+
scratchVersion = 0;
|
|
83
|
+
/** past Lean's handshake */
|
|
84
|
+
ready = false;
|
|
85
|
+
/** what Lean last published about each document it has open: the version it is about, and the
|
|
86
|
+
* messages (a `#print` says what it prints so) */
|
|
87
|
+
diagnostics = new Map();
|
|
88
|
+
constructor(project, cacheDir) {
|
|
89
|
+
this.project = project;
|
|
90
|
+
this.dir = join(cacheDir, "lean-server");
|
|
91
|
+
}
|
|
92
|
+
/** The project's `lean` and LEAN_PATH, found once. */
|
|
93
|
+
resolveEnv() {
|
|
94
|
+
if (this.env)
|
|
95
|
+
return this.env;
|
|
96
|
+
const root = this.project.root;
|
|
97
|
+
const which = spawnSync("elan", ["which", "lean"], { cwd: root, encoding: "utf8", timeout: 60000 });
|
|
98
|
+
const lean = which.status === 0 && which.stdout.trim() ? which.stdout.trim() : "lean";
|
|
99
|
+
const env = spawnSync("lake", ["env", "printenv", "LEAN_PATH"], { cwd: root, encoding: "utf8", timeout: 120000 });
|
|
100
|
+
if (env.status !== 0)
|
|
101
|
+
throw new Error(`lake env failed: ${(env.stderr || env.stdout || "").trim().slice(0, 300)}`);
|
|
102
|
+
this.env = { lean, leanPath: env.stdout.trim() };
|
|
103
|
+
return this.env;
|
|
104
|
+
}
|
|
105
|
+
/** Lean, started and past the handshake. Every caller waits for the same start: a request sent
|
|
106
|
+
* before `initialized` makes Lean's server exit. */
|
|
107
|
+
start() {
|
|
108
|
+
this.starting ??= (async () => {
|
|
109
|
+
const { lean, leanPath } = this.resolveEnv();
|
|
110
|
+
if (!existsSync(this.dir))
|
|
111
|
+
mkdirSync(this.dir, { recursive: true });
|
|
112
|
+
const p = spawn(lean, ["--server"], { cwd: this.dir, env: { ...process.env, LEAN_PATH: leanPath }, stdio: ["pipe", "pipe", "pipe"] });
|
|
113
|
+
this.proc = p;
|
|
114
|
+
// what Lean writes is read here, in a callback no request waits on: nothing it writes may
|
|
115
|
+
// throw out of it, or the server stops (read() skips what it cannot read)
|
|
116
|
+
p.stdout.on("data", (d) => {
|
|
117
|
+
if (this.proc !== p)
|
|
118
|
+
return;
|
|
119
|
+
try {
|
|
120
|
+
this.read(d);
|
|
121
|
+
}
|
|
122
|
+
catch (e) {
|
|
123
|
+
this.skip(`Lean's output: ${e instanceof Error ? e.message : String(e)}`);
|
|
124
|
+
this.buf = Buffer.alloc(0);
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
// what Lean last said on stderr: the reason, when it stops
|
|
128
|
+
let said = "";
|
|
129
|
+
p.stderr.on("data", (d) => (said = (said + d.toString()).slice(-2000)));
|
|
130
|
+
// a Lean that stopped closes its pipes: writing then fails, and that must not stop the server
|
|
131
|
+
p.stdin.on("error", () => { });
|
|
132
|
+
p.on("error", (e) => (this.failure = e.message));
|
|
133
|
+
p.on("exit", (code, signal) => {
|
|
134
|
+
// stopped by stop(), this is not the running Lean any more and everything was reset there
|
|
135
|
+
if (this.proc !== p)
|
|
136
|
+
return;
|
|
137
|
+
this.failure = `Lean stopped (${signal ?? `exit ${code}`})${said.trim() ? `: ${said.trim().slice(-300)}` : ""}`;
|
|
138
|
+
this.reset();
|
|
139
|
+
});
|
|
140
|
+
const init = await this.request("initialize", { processId: process.pid, rootUri: null, capabilities: {} });
|
|
141
|
+
if (init.error) {
|
|
142
|
+
const why = this.failure ?? init.error.message;
|
|
143
|
+
this.reset();
|
|
144
|
+
p.kill();
|
|
145
|
+
throw new Error(why);
|
|
146
|
+
}
|
|
147
|
+
this.notify("initialized", {});
|
|
148
|
+
this.failure = null;
|
|
149
|
+
this.ready = true;
|
|
150
|
+
})().catch((e) => {
|
|
151
|
+
this.starting = null;
|
|
152
|
+
this.failure = e instanceof Error ? e.message : String(e);
|
|
153
|
+
throw e;
|
|
154
|
+
});
|
|
155
|
+
return this.starting;
|
|
156
|
+
}
|
|
157
|
+
/** Lean's output, framed as LSP frames it (`Content-Length`, then the JSON): each frame read in
|
|
158
|
+
* turn. A frame that cannot be read is skipped and said, never thrown. */
|
|
159
|
+
read(d) {
|
|
160
|
+
this.buf = Buffer.concat([this.buf, d]);
|
|
161
|
+
for (;;) {
|
|
162
|
+
const h = this.buf.indexOf("\r\n\r\n");
|
|
163
|
+
if (h < 0) {
|
|
164
|
+
// a header that never ends is not Lean's protocol: dropped rather than kept growing
|
|
165
|
+
if (this.buf.length > 1 << 16) {
|
|
166
|
+
this.skip("a header with no end");
|
|
167
|
+
this.buf = Buffer.alloc(0);
|
|
168
|
+
}
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
const length = /Content-Length: (\d+)/i.exec(this.buf.subarray(0, h).toString());
|
|
172
|
+
if (!length) {
|
|
173
|
+
this.skip("a header without Content-Length");
|
|
174
|
+
this.buf = this.buf.subarray(h + 4);
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
const len = Number(length[1]);
|
|
178
|
+
if (this.buf.length < h + 4 + len)
|
|
179
|
+
return;
|
|
180
|
+
const text = this.buf.subarray(h + 4, h + 4 + len).toString();
|
|
181
|
+
this.buf = this.buf.subarray(h + 4 + len);
|
|
182
|
+
let msg;
|
|
183
|
+
try {
|
|
184
|
+
msg = JSON.parse(text);
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
this.skip("a frame that is not JSON");
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
try {
|
|
191
|
+
this.take(msg);
|
|
192
|
+
}
|
|
193
|
+
catch (e) {
|
|
194
|
+
this.skip(`a frame not as expected (${e instanceof Error ? e.message : String(e)})`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
/** One message from Lean: an answer, how far a document is, what Lean says about one. */
|
|
199
|
+
take(msg) {
|
|
200
|
+
if (!msg || typeof msg !== "object")
|
|
201
|
+
return;
|
|
202
|
+
if (msg.id !== undefined && this.waiting.has(msg.id)) {
|
|
203
|
+
this.waiting.get(msg.id)(msg);
|
|
204
|
+
this.waiting.delete(msg.id);
|
|
205
|
+
}
|
|
206
|
+
else if (msg.method === "$/lean/fileProgress") {
|
|
207
|
+
const uri = msg.params?.textDocument?.uri;
|
|
208
|
+
const ranges = (Array.isArray(msg.params?.processing) ? msg.params.processing : []);
|
|
209
|
+
if (typeof uri !== "string")
|
|
210
|
+
return;
|
|
211
|
+
// the worker for the document crashed: Lean waits for a change before it starts another (one
|
|
212
|
+
// said of a version before the present one is about a worker already replaced)
|
|
213
|
+
if (ranges.some((r) => r.kind === FATAL)) {
|
|
214
|
+
const now = this.docOf(uri)?.version ?? (this.scratch?.uri === uri ? this.scratch.version : null);
|
|
215
|
+
const of = msg.params.textDocument.version;
|
|
216
|
+
return now !== null && (typeof of !== "number" || of >= now) ? this.crashed(uri) : undefined;
|
|
217
|
+
}
|
|
218
|
+
const doc = this.docOf(uri);
|
|
219
|
+
if (doc) {
|
|
220
|
+
doc.processing = ranges.length ? Math.min(...ranges.map((r) => r.range?.start?.line ?? 0)) : -1;
|
|
221
|
+
doc.progressAt = Date.now();
|
|
222
|
+
if (!ranges.length)
|
|
223
|
+
this.crashes.delete(uri);
|
|
224
|
+
}
|
|
225
|
+
const sc = this.scratch;
|
|
226
|
+
if (sc && sc.uri === uri && msg.params.textDocument.version === sc.version) {
|
|
227
|
+
sc.done = !ranges.length;
|
|
228
|
+
sc.progressAt = Date.now();
|
|
229
|
+
if (sc.done)
|
|
230
|
+
this.crashes.delete(uri);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
else if (msg.method === "textDocument/publishDiagnostics") {
|
|
234
|
+
// each publication holds every message about the document so far: the last one is kept
|
|
235
|
+
if (typeof msg.params?.uri !== "string")
|
|
236
|
+
return;
|
|
237
|
+
this.diagnostics.set(msg.params.uri, { version: msg.params.version ?? null, items: Array.isArray(msg.params.diagnostics) ? msg.params.diagnostics : [] });
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
/** Something of Lean's output was not read: said, at most once a minute. */
|
|
241
|
+
skip(what) {
|
|
242
|
+
this.garbled.count++;
|
|
243
|
+
if (Date.now() - this.garbled.said < 60_000)
|
|
244
|
+
return;
|
|
245
|
+
this.garbled.said = Date.now();
|
|
246
|
+
console.error(`lean: skipped ${what} (${this.garbled.count} so far)`);
|
|
247
|
+
}
|
|
248
|
+
docOf(uri) {
|
|
249
|
+
for (const d of this.docs.values())
|
|
250
|
+
if (d.uri === uri)
|
|
251
|
+
return d;
|
|
252
|
+
return undefined;
|
|
253
|
+
}
|
|
254
|
+
/** Lean's worker for document `uri` stopped (or stalled: `why`). Lean starts it again only when
|
|
255
|
+
* the document changes, and until then keeps every question about it waiting: the document is
|
|
256
|
+
* closed and opened again, as a new version, so that it is elaborated afresh and the questions
|
|
257
|
+
* asked anew. One that keeps crashing — it never got to its end in between — is closed and left
|
|
258
|
+
* for GIVE_UP_MS: what is asked about it meanwhile is answered with why. */
|
|
259
|
+
crashed(uri, why = "crashed") {
|
|
260
|
+
const doc = this.docOf(uri);
|
|
261
|
+
const sc = this.scratch?.uri === uri ? this.scratch : null;
|
|
262
|
+
const open = doc ?? sc;
|
|
263
|
+
if (!open)
|
|
264
|
+
return;
|
|
265
|
+
this.restarts++;
|
|
266
|
+
const n = (this.crashes.get(uri) ?? 0) + 1;
|
|
267
|
+
this.crashes.set(uri, n);
|
|
268
|
+
this.diagnostics.delete(uri);
|
|
269
|
+
this.asked.clear();
|
|
270
|
+
this.notify("textDocument/didClose", { textDocument: { uri } });
|
|
271
|
+
const name = doc ? [...this.docs.entries()].find(([, d]) => d === doc)[0] : "the scratch file";
|
|
272
|
+
console.error(`lean: the worker for ${name} ${why} (${n} in a row)`);
|
|
273
|
+
if (n > MAX_CRASHES) {
|
|
274
|
+
this.givenUp.set(uri, { why: `Lean's worker for ${name} ${why} ${n} times in a row (out of memory, or a fault in Lean); it is started again in ${Math.round(GIVE_UP_MS / 60_000)} minutes.`, at: Date.now() });
|
|
275
|
+
this.crashes.delete(uri);
|
|
276
|
+
if (doc)
|
|
277
|
+
for (const [k, d] of this.docs)
|
|
278
|
+
if (d === doc)
|
|
279
|
+
this.docs.delete(k);
|
|
280
|
+
if (sc)
|
|
281
|
+
this.scratch = null;
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
open.version = sc ? ++this.scratchVersion : open.version + 1;
|
|
285
|
+
open.progressAt = Date.now();
|
|
286
|
+
if (doc)
|
|
287
|
+
doc.processing = 0;
|
|
288
|
+
if (sc)
|
|
289
|
+
sc.done = false;
|
|
290
|
+
this.notify("textDocument/didOpen", { textDocument: { uri, languageId: "lean4", version: open.version, text: open.text } });
|
|
291
|
+
}
|
|
292
|
+
/** Why document `uri` is not opened now (its worker kept crashing), or null. */
|
|
293
|
+
refused(uri) {
|
|
294
|
+
const g = this.givenUp.get(uri);
|
|
295
|
+
if (!g)
|
|
296
|
+
return null;
|
|
297
|
+
if (Date.now() - g.at < GIVE_UP_MS)
|
|
298
|
+
return g.why;
|
|
299
|
+
this.givenUp.delete(uri);
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
302
|
+
/** A document Lean is elaborating and has said nothing about for STALL_MS has stopped: it is
|
|
303
|
+
* opened again as a crashed one. */
|
|
304
|
+
unstall(open, busy) {
|
|
305
|
+
if (busy && Date.now() - open.progressAt > STALL_MS)
|
|
306
|
+
this.crashed(open.uri, `said nothing for ${Math.round(STALL_MS / 60_000)} minutes`);
|
|
307
|
+
}
|
|
308
|
+
/** What Lean's language server is doing (/api/status). */
|
|
309
|
+
state() {
|
|
310
|
+
const now = Date.now();
|
|
311
|
+
const fileOf = (uri) => [...this.docs.entries()].find(([, d]) => d.uri === uri)?.[0] ?? decodeURIComponent(uri.replace(/^.*\//, ""));
|
|
312
|
+
return {
|
|
313
|
+
state: this.proc ? (this.ready ? "running" : "starting") : this.failure ? "failed" : "off",
|
|
314
|
+
failure: this.failure,
|
|
315
|
+
restarts: this.restarts,
|
|
316
|
+
files: [...this.docs.entries()].map(([file, d]) => ({ file, version: d.version, elaborated: d.processing < 0 ? "done" : { line: d.processing, of: d.lines } })),
|
|
317
|
+
scratch: this.scratch ? { module: this.scratch.text.split("\n")[0].replace(/^import /, ""), command: this.scratch.text.split("\n")[1] ?? "", done: this.scratch.done } : null,
|
|
318
|
+
givenUp: [...this.givenUp.entries()].filter(([, g]) => now - g.at < GIVE_UP_MS).map(([uri, g]) => ({ file: fileOf(uri), why: g.why, until: new Date(g.at + GIVE_UP_MS).toISOString() })),
|
|
319
|
+
waiting: this.waiting.size,
|
|
320
|
+
queued: this.queued,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
send(m) {
|
|
324
|
+
const s = JSON.stringify({ jsonrpc: "2.0", ...m });
|
|
325
|
+
const p = this.proc;
|
|
326
|
+
if (p && p.stdin.writable)
|
|
327
|
+
p.stdin.write(`Content-Length: ${Buffer.byteLength(s)}\r\n\r\n${s}`);
|
|
328
|
+
}
|
|
329
|
+
notify(method, params) {
|
|
330
|
+
this.send({ method, params });
|
|
331
|
+
}
|
|
332
|
+
/** A question to Lean, answered by Lean, or after `timeoutMs` that it did not answer: nothing
|
|
333
|
+
* waits for Lean forever. Asked of a Lean that is not running, it is answered so at once. */
|
|
334
|
+
request(method, params, timeoutMs = REQUEST_MS) {
|
|
335
|
+
const id = ++this.nextId;
|
|
336
|
+
return new Promise((ok) => {
|
|
337
|
+
if (!this.proc?.stdin?.writable)
|
|
338
|
+
return ok({ error: { message: "Lean is not running" } });
|
|
339
|
+
const timer = setTimeout(() => {
|
|
340
|
+
if (!this.waiting.delete(id))
|
|
341
|
+
return;
|
|
342
|
+
ok({ error: { message: `Lean did not answer in ${Math.round(timeoutMs / 1000)} s` } });
|
|
343
|
+
}, timeoutMs);
|
|
344
|
+
timer.unref();
|
|
345
|
+
this.waiting.set(id, (msg) => {
|
|
346
|
+
clearTimeout(timer);
|
|
347
|
+
ok(msg);
|
|
348
|
+
});
|
|
349
|
+
this.send({ id, method, params });
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
touch() {
|
|
353
|
+
if (this.idle)
|
|
354
|
+
clearTimeout(this.idle);
|
|
355
|
+
this.idle = setTimeout(() => this.stop(), IDLE_MS);
|
|
356
|
+
this.idle.unref();
|
|
357
|
+
}
|
|
358
|
+
/** Have Lean elaborate a file (its present text), keeping at most KEEP_OPEN open. */
|
|
359
|
+
async open(rel, text, hash) {
|
|
360
|
+
await this.start();
|
|
361
|
+
this.touch();
|
|
362
|
+
const uri = pathToFileURL(join(this.project.root, rel)).href;
|
|
363
|
+
let doc = this.docs.get(rel);
|
|
364
|
+
if (doc)
|
|
365
|
+
this.unstall(doc, doc.processing >= 0);
|
|
366
|
+
const no = this.refused(uri);
|
|
367
|
+
if (no)
|
|
368
|
+
throw new Error(no);
|
|
369
|
+
doc = this.docs.get(rel);
|
|
370
|
+
if (doc && doc.hash !== hash) {
|
|
371
|
+
// the file changed: Lean takes the new text and elaborates again from where it differs
|
|
372
|
+
doc.version++;
|
|
373
|
+
doc.hash = hash;
|
|
374
|
+
doc.text = text;
|
|
375
|
+
doc.lines = text.split("\n").length;
|
|
376
|
+
doc.processing = 0;
|
|
377
|
+
doc.progressAt = Date.now();
|
|
378
|
+
this.asked.clear();
|
|
379
|
+
this.notify("textDocument/didChange", { textDocument: { uri, version: doc.version }, contentChanges: [{ text }] });
|
|
380
|
+
}
|
|
381
|
+
else if (!doc) {
|
|
382
|
+
doc = { uri, version: 1, text, progressAt: Date.now(), hash, lines: text.split("\n").length, used: 0, processing: 0 };
|
|
383
|
+
this.docs.set(rel, doc);
|
|
384
|
+
this.notify("textDocument/didOpen", { textDocument: { uri, languageId: "lean4", version: 1, text } });
|
|
385
|
+
// too many open: the one least recently asked about is closed
|
|
386
|
+
if (this.docs.size > KEEP_OPEN) {
|
|
387
|
+
const [old] = [...this.docs.entries()].filter(([k]) => k !== rel).sort((a, b) => a[1].used - b[1].used)[0];
|
|
388
|
+
this.notify("textDocument/didClose", { textDocument: { uri: this.docs.get(old).uri } });
|
|
389
|
+
this.diagnostics.delete(this.docs.get(old).uri);
|
|
390
|
+
this.docs.delete(old);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
doc.used = Date.now();
|
|
394
|
+
return doc;
|
|
395
|
+
}
|
|
396
|
+
/** What is at (line, character) of the file — UTF-16 columns, as the page counts them. Waits
|
|
397
|
+
* up to `waitMs` for Lean to elaborate that far, then says how far it is. */
|
|
398
|
+
async hover(rel, text, hash, line, character, waitMs) {
|
|
399
|
+
let doc;
|
|
400
|
+
try {
|
|
401
|
+
doc = await this.open(rel, text, hash);
|
|
402
|
+
}
|
|
403
|
+
catch (e) {
|
|
404
|
+
return { error: this.failure ?? (e instanceof Error ? e.message : String(e)) };
|
|
405
|
+
}
|
|
406
|
+
const msg = await Promise.race([this.ask(doc, line, character), after(waitMs)]);
|
|
407
|
+
if (!msg)
|
|
408
|
+
return { pending: { line: doc.processing < 0 ? doc.lines : doc.processing, of: doc.lines } };
|
|
409
|
+
if (msg.error)
|
|
410
|
+
return { error: msg.error.message };
|
|
411
|
+
const r = msg.result;
|
|
412
|
+
return { contents: r?.contents?.value ?? null };
|
|
413
|
+
}
|
|
414
|
+
/** What Lean says each position of a file names, asked all at once: the first word of the
|
|
415
|
+
* hover's code — a declaration's full name where the position refers to one, a variable's name
|
|
416
|
+
* where it is one — or null where Lean has nothing to say. This is how the source window links
|
|
417
|
+
* every name to what it means, dot notation and `open`s included, as go-to-definition would. */
|
|
418
|
+
async names(rel, text, hash, positions, waitMs) {
|
|
419
|
+
let doc;
|
|
420
|
+
try {
|
|
421
|
+
doc = await this.open(rel, text, hash);
|
|
422
|
+
}
|
|
423
|
+
catch (e) {
|
|
424
|
+
return { error: this.failure ?? (e instanceof Error ? e.message : String(e)) };
|
|
425
|
+
}
|
|
426
|
+
const all = Promise.all(positions.map(([line, character]) => this.ask(doc, line, character)));
|
|
427
|
+
const msgs = await Promise.race([all, after(waitMs)]);
|
|
428
|
+
if (!msgs)
|
|
429
|
+
return { pending: { line: doc.processing < 0 ? doc.lines : doc.processing, of: doc.lines } };
|
|
430
|
+
return {
|
|
431
|
+
heads: msgs.map((m) => {
|
|
432
|
+
const md = m.result?.contents?.value;
|
|
433
|
+
const first = md ? /^```[^\n]*\n(\S+)/.exec(md)?.[1] : undefined;
|
|
434
|
+
// `Option.elim.{u_1, u_2} …`: the universes are not part of the name
|
|
435
|
+
return first ? first.replace(/\.\{.*$/, "") : null;
|
|
436
|
+
}),
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
/** What Lean says about the declaration `name` — its signature and doc, as its hover shows
|
|
440
|
+
* them — where `module` is imported. `name` must be a name and nothing else
|
|
441
|
+
* (`isDeclName`): it is written into Lean source. */
|
|
442
|
+
describe(module, name, waitMs) {
|
|
443
|
+
const deadline = Date.now() + waitMs;
|
|
444
|
+
return this.queue(() => this.describeNow(module, name, deadline), { pending: { line: 0, of: 2 } });
|
|
445
|
+
}
|
|
446
|
+
/** What Lean's `#print` says about `name` where `module` is imported: the elaborated declaration,
|
|
447
|
+
* with its value — what a macro generated, the proposition a Prop-valued definition stands for.
|
|
448
|
+
* Without `proofs`, a proof is shown as `⋯` (a theorem's value is its proof). `name` must be a
|
|
449
|
+
* name and nothing else (`isDeclName`): it is written into Lean source. */
|
|
450
|
+
print(module, name, proofs, waitMs) {
|
|
451
|
+
const deadline = Date.now() + waitMs;
|
|
452
|
+
return this.queue(() => this.printNow(module, name, proofs, deadline), { pending: { line: 0, of: 2 } });
|
|
453
|
+
}
|
|
454
|
+
/** One question in the scratch file at a time: each changes the file. Each waits from when it
|
|
455
|
+
* was asked, not from its turn; and no more than MAX_QUEUED wait at once: one more is answered
|
|
456
|
+
* `busy` at once (asked again, it is the pending answer: a 503 with Retry-After for an agent). */
|
|
457
|
+
queue(ask, busy) {
|
|
458
|
+
if (this.queued >= MAX_QUEUED)
|
|
459
|
+
return Promise.resolve(busy);
|
|
460
|
+
this.queued++;
|
|
461
|
+
const run = this.describing.then(ask).finally(() => this.queued--);
|
|
462
|
+
this.describing = run.catch(() => { });
|
|
463
|
+
return run;
|
|
464
|
+
}
|
|
465
|
+
async describeNow(module, name, deadline) {
|
|
466
|
+
if (!isDeclName(name) || !isDeclName(module))
|
|
467
|
+
return { error: "not a name" };
|
|
468
|
+
const sc = await this.check(module, `#check @${name}`, deadline);
|
|
469
|
+
if (!("uri" in sc))
|
|
470
|
+
return sc;
|
|
471
|
+
// a name Lean does not know has no hover of its own: the one at its place is the `@`'s, which
|
|
472
|
+
// explains `@`. What `#check` said tells the two apart
|
|
473
|
+
const said = await this.said(sc, deadline);
|
|
474
|
+
if (said.error)
|
|
475
|
+
return { error: said.error };
|
|
476
|
+
const refused = said.messages.find((m) => m.severity === "error");
|
|
477
|
+
if (refused)
|
|
478
|
+
return { contents: null, refused: refused.text };
|
|
479
|
+
const hover = this.request("textDocument/hover", { textDocument: { uri: sc.uri }, position: { line: 1, character: 8 } });
|
|
480
|
+
const msg = await Promise.race([hover, after(Math.max(1000, deadline - Date.now()))]);
|
|
481
|
+
// the import is still being loaded
|
|
482
|
+
if (!msg)
|
|
483
|
+
return { pending: { line: 0, of: 2 } };
|
|
484
|
+
if (msg.error)
|
|
485
|
+
return { error: msg.error.message };
|
|
486
|
+
return { contents: msg.result?.contents?.value ?? null };
|
|
487
|
+
}
|
|
488
|
+
async printNow(module, name, proofs, deadline) {
|
|
489
|
+
if (!isDeclName(name) || !isDeclName(module))
|
|
490
|
+
return { error: "not a name" };
|
|
491
|
+
// one line, so that every message about it is on line 1
|
|
492
|
+
const sc = await this.check(module, `${proofs ? "" : "set_option pp.proofs false in "}#print ${name}`, deadline);
|
|
493
|
+
if (!("uri" in sc))
|
|
494
|
+
return sc;
|
|
495
|
+
return this.said(sc, deadline);
|
|
496
|
+
}
|
|
497
|
+
/** What Lean said about the scratch file's command, once it has checked it. The messages can
|
|
498
|
+
* come just after Lean says it is done: wait for those about this version of the file and about
|
|
499
|
+
* the command (every command says something, if only an error). */
|
|
500
|
+
async said(sc, deadline) {
|
|
501
|
+
const about = () => {
|
|
502
|
+
const d = this.diagnostics.get(sc.uri);
|
|
503
|
+
return d && d.version === sc.version ? d.items.filter((x) => (x.range?.start?.line ?? 0) >= 1) : [];
|
|
504
|
+
};
|
|
505
|
+
const until = Math.max(deadline, Date.now() + 2000);
|
|
506
|
+
while (!about().length && this.scratch === sc && Date.now() < until)
|
|
507
|
+
await new Promise((ok) => setTimeout(ok, 20));
|
|
508
|
+
if (this.scratch !== sc)
|
|
509
|
+
return { error: "Lean stopped" };
|
|
510
|
+
const items = about();
|
|
511
|
+
if (!items.length)
|
|
512
|
+
return { error: "Lean said nothing about the command" };
|
|
513
|
+
return { messages: items.map((x) => ({ severity: SEVERITY[x.severity ?? 1] ?? "error", text: x.message })) };
|
|
514
|
+
}
|
|
515
|
+
/** Have Lean check `command` where `module` is imported, in the scratch file: the file, once Lean
|
|
516
|
+
* has checked it; else how far it got by the deadline, or why it cannot. */
|
|
517
|
+
async check(module, command, deadline) {
|
|
518
|
+
try {
|
|
519
|
+
await this.start();
|
|
520
|
+
}
|
|
521
|
+
catch (e) {
|
|
522
|
+
return { error: this.failure ?? (e instanceof Error ? e.message : String(e)) };
|
|
523
|
+
}
|
|
524
|
+
this.touch();
|
|
525
|
+
const text = `import ${module}\n${command}\n`;
|
|
526
|
+
const uri = pathToFileURL(join(this.dir, "Describe.lean")).href;
|
|
527
|
+
if (this.scratch)
|
|
528
|
+
this.unstall(this.scratch, !this.scratch.done);
|
|
529
|
+
const no = this.refused(uri);
|
|
530
|
+
if (no)
|
|
531
|
+
return { error: no };
|
|
532
|
+
if (!this.scratch) {
|
|
533
|
+
this.scratch = { uri, version: ++this.scratchVersion, text, done: false, progressAt: Date.now() };
|
|
534
|
+
this.notify("textDocument/didOpen", { textDocument: { uri, languageId: "lean4", version: this.scratch.version, text } });
|
|
535
|
+
}
|
|
536
|
+
else if (this.scratch.text !== text) {
|
|
537
|
+
// the import line stays while the same module is on show: only the command is elaborated again
|
|
538
|
+
this.scratch.version = ++this.scratchVersion;
|
|
539
|
+
this.scratch.text = text;
|
|
540
|
+
this.scratch.done = false;
|
|
541
|
+
this.scratch.progressAt = Date.now();
|
|
542
|
+
this.notify("textDocument/didChange", { textDocument: { uri: this.scratch.uri, version: this.scratch.version }, contentChanges: [{ text }] });
|
|
543
|
+
}
|
|
544
|
+
// asked before the import is read, Lean answers that there is nothing there: wait until it has
|
|
545
|
+
// checked the file (a crash opens the same file again, as a newer version, and it is waited for)
|
|
546
|
+
const sc = this.scratch;
|
|
547
|
+
while (!sc.done && this.scratch === sc && Date.now() < deadline)
|
|
548
|
+
await new Promise((ok) => setTimeout(ok, 30));
|
|
549
|
+
if (this.scratch !== sc)
|
|
550
|
+
return { error: this.refused(uri) ?? "Lean stopped" };
|
|
551
|
+
if (!sc.done)
|
|
552
|
+
return { pending: { line: 0, of: 2 } };
|
|
553
|
+
return sc;
|
|
554
|
+
}
|
|
555
|
+
/** One hover request; a request for the same place while one is in flight joins it. */
|
|
556
|
+
ask(doc, line, character) {
|
|
557
|
+
const key = `${doc.uri}:${doc.version}:${line}:${character}`;
|
|
558
|
+
let p = this.asked.get(key);
|
|
559
|
+
if (!p) {
|
|
560
|
+
const asked = this.request("textDocument/hover", { textDocument: { uri: doc.uri }, position: { line, character } });
|
|
561
|
+
p = asked;
|
|
562
|
+
this.asked.set(key, p);
|
|
563
|
+
// an answer is kept a while for the retries that join it; a failure (a crash, no answer) is
|
|
564
|
+
// forgotten at once, so that the next asks again
|
|
565
|
+
void p.then((m) => (m.error ? this.asked.get(key) === asked && this.asked.delete(key) : setTimeout(() => this.asked.get(key) === asked && this.asked.delete(key), 30000).unref()));
|
|
566
|
+
}
|
|
567
|
+
return p;
|
|
568
|
+
}
|
|
569
|
+
/** Forget the running Lean: what it had open, and the questions it will not answer now. */
|
|
570
|
+
reset() {
|
|
571
|
+
this.ready = false;
|
|
572
|
+
this.proc = null;
|
|
573
|
+
this.buf = Buffer.alloc(0);
|
|
574
|
+
this.starting = null;
|
|
575
|
+
this.scratch = null;
|
|
576
|
+
this.diagnostics.clear();
|
|
577
|
+
this.docs.clear();
|
|
578
|
+
this.asked.clear();
|
|
579
|
+
for (const w of this.waiting.values())
|
|
580
|
+
w({ error: { message: "Lean stopped" } });
|
|
581
|
+
this.waiting.clear();
|
|
582
|
+
}
|
|
583
|
+
stop() {
|
|
584
|
+
if (this.idle)
|
|
585
|
+
clearTimeout(this.idle);
|
|
586
|
+
this.idle = null;
|
|
587
|
+
const p = this.proc;
|
|
588
|
+
this.reset();
|
|
589
|
+
p?.kill();
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
/** Null after `ms`: what a question for Lean is raced against. */
|
|
593
|
+
function after(ms) {
|
|
594
|
+
return new Promise((ok) => setTimeout(() => ok(null), ms).unref());
|
|
595
|
+
}
|