visualproof 0.0.0-stage → 0.2.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/assets/webfonts/LICENSE-Jost.txt +93 -0
- package/dist/assets/webfonts/LICENSE-SourceSans3.txt +93 -0
- package/dist/assets/webfonts/LICENSE-SourceSerif4.txt +93 -0
- package/dist/assets/webfonts/jost-latin-ext-wght-normal.woff2 +0 -0
- package/dist/assets/webfonts/jost-latin-wght-normal.woff2 +0 -0
- package/dist/assets/webfonts/source-sans-3-greek-wght-italic.woff2 +0 -0
- package/dist/assets/webfonts/source-sans-3-greek-wght-normal.woff2 +0 -0
- package/dist/assets/webfonts/source-sans-3-latin-ext-wght-italic.woff2 +0 -0
- package/dist/assets/webfonts/source-sans-3-latin-ext-wght-normal.woff2 +0 -0
- package/dist/assets/webfonts/source-sans-3-latin-wght-italic.woff2 +0 -0
- package/dist/assets/webfonts/source-sans-3-latin-wght-normal.woff2 +0 -0
- package/dist/assets/webfonts/source-serif-4-greek-wght-italic.woff2 +0 -0
- package/dist/assets/webfonts/source-serif-4-greek-wght-normal.woff2 +0 -0
- package/dist/assets/webfonts/source-serif-4-latin-ext-wght-italic.woff2 +0 -0
- package/dist/assets/webfonts/source-serif-4-latin-ext-wght-normal.woff2 +0 -0
- package/dist/assets/webfonts/source-serif-4-latin-wght-italic.woff2 +0 -0
- package/dist/assets/webfonts/source-serif-4-latin-wght-normal.woff2 +0 -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 +3270 -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 +780 -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 +1406 -0
- package/dist/server/mcptools.js +496 -0
- package/dist/server/pastdoc.js +73 -0
- package/dist/server/pipeline.js +159 -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 +50 -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 +164 -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 +241 -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 +124 -0
- package/dist/shared/doc/outline.js +45 -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 +244 -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 +103 -0
- package/dist/shared/places.js +80 -0
- package/dist/shared/projectnotes.js +244 -0
- package/dist/shared/query.js +818 -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 +281 -0
- package/dist/shared/sticky.js +501 -0
- package/dist/shared/timeline.js +73 -0
- package/dist/shared/titles.js +300 -0
- package/dist/shared/viewgraph.js +157 -0
- package/dist/shared/views.js +859 -0
- package/dist/web/bundle/app.js +52671 -0
- package/dist/web/bundle/edges.js +407 -0
- package/dist/web/bundle/layout.js +1010 -0
- package/dist/web/bundle/pdfworker.js +48322 -0
- package/dist/web/index.html +257 -0
- package/dist/web/src/motion.js +430 -0
- package/dist/web/src/past.js +224 -0
- package/dist/web/src/scene.js +804 -0
- package/dist/web/style.css +2548 -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 +125 -0
- package/docs/agent-api/words.md +56 -0
- package/package.json +39 -4
- package/test/toolchains.json +5 -0
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// The exported document's order (the owner's decisions of 2026-10-12): the board is the outline of
|
|
2
|
+
// the document. Within a section, everything the board draws in that cluster — its sub-clusters by
|
|
3
|
+
// their circle's centre, its declarations by their point, its notes by their icon — comes in the
|
|
4
|
+
// order of its height on the view's board, the lowest first, ties left to right; the top-level
|
|
5
|
+
// sections are ordered so too, and so is what the document starts with, under no heading: the
|
|
6
|
+
// declarations in no cluster and the notes on the ring or on the canvas outside every cluster. Since
|
|
7
|
+
// the layout puts what is used more lower down (CanvasSpec, "Dependency order"), the default reads
|
|
8
|
+
// foundations first; a reader reorders the document by dragging things on the board. Nothing here
|
|
9
|
+
// reads what depends on what.
|
|
10
|
+
//
|
|
11
|
+
// Where each thing is drawn is the board's (server/boardspots.ts reads it as the page draws it: the
|
|
12
|
+
// view's layout memory, its pins and its hidden levels); this file only orders what it is told, so
|
|
13
|
+
// that the same positions always give the same document. A note attached to a declaration the
|
|
14
|
+
// document prints goes right after it, as its explanation; a note in a cluster goes to the section
|
|
15
|
+
// of the nearest cluster around it that the document prints; any other note, to the start.
|
|
16
|
+
import { boardLayout, subgraph } from "../board.js";
|
|
17
|
+
/** A height on the board, read to a tenth of a unit, which the layout rounds its places to: two
|
|
18
|
+
* things on one row of a cluster's lattice are level, and go left to right. */
|
|
19
|
+
const tenth = (v) => Math.round(v * 10);
|
|
20
|
+
/** The board's order of things at `at` (null where the board does not say: last, as given): the
|
|
21
|
+
* lowest first, ties left to right, then as given. */
|
|
22
|
+
export function boardOrder(xs, at) {
|
|
23
|
+
const keyed = xs.map((x, k) => ({ x, k, p: at(x) }));
|
|
24
|
+
keyed.sort((a, b) => {
|
|
25
|
+
if (!a.p || !b.p)
|
|
26
|
+
return a.p ? -1 : b.p ? 1 : a.k - b.k;
|
|
27
|
+
return tenth(b.p[1]) - tenth(a.p[1]) || tenth(a.p[0]) - tenth(b.p[0]) || a.k - b.k;
|
|
28
|
+
});
|
|
29
|
+
return keyed.map((e) => e.x);
|
|
30
|
+
}
|
|
31
|
+
/** Where `a` goes on the board to come between `prev` and `next` in the board's order (`prev` read
|
|
32
|
+
* before it, so lower; `next` after, higher), all three of one cluster: the linear form's drop
|
|
33
|
+
* (ExportSpec decision 18, web/src/linear.ts). At a height between theirs where they are across,
|
|
34
|
+
* where `a` is across; on their row, between them, where they are level; below the first, above
|
|
35
|
+
* the last, clear of it. Null where neither is given. */
|
|
36
|
+
export function spotBetween(a, prev, next) {
|
|
37
|
+
if (prev && next) {
|
|
38
|
+
if (Math.abs(prev.y - next.y) >= 1)
|
|
39
|
+
return [a.x, (prev.y + next.y) / 2];
|
|
40
|
+
return [(prev.x + next.x) / 2, prev.y];
|
|
41
|
+
}
|
|
42
|
+
if (next)
|
|
43
|
+
return [a.x, next.y + next.r + a.r + 2.5];
|
|
44
|
+
if (prev)
|
|
45
|
+
return [a.x, prev.y - prev.r - a.r - 2.5];
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
/** The outline of the document of `plan` (of graph `g`) in the order of the board `spots` describes. */
|
|
49
|
+
export function orderDoc(plan, g, spots) {
|
|
50
|
+
// the declarations the document prints, by name: where an attached note goes
|
|
51
|
+
const printed = new Map();
|
|
52
|
+
const sectionOf = new Map();
|
|
53
|
+
const walk = (s) => {
|
|
54
|
+
for (const k of s.keys)
|
|
55
|
+
sectionOf.set(k, s);
|
|
56
|
+
for (const i of s.decls)
|
|
57
|
+
printed.set(g.nodes[i].name, i);
|
|
58
|
+
s.sections.forEach(walk);
|
|
59
|
+
};
|
|
60
|
+
plan.sections.forEach(walk);
|
|
61
|
+
for (const i of plan.decls)
|
|
62
|
+
printed.set(g.nodes[i].name, i);
|
|
63
|
+
const attached = new Map();
|
|
64
|
+
const inSection = new Map();
|
|
65
|
+
const atStart = [];
|
|
66
|
+
spots.notes.forEach((n, k) => {
|
|
67
|
+
const on = n.on !== undefined ? printed.get(n.on) : undefined;
|
|
68
|
+
if (on !== undefined) {
|
|
69
|
+
const list = attached.get(on) ?? [];
|
|
70
|
+
list.push(k);
|
|
71
|
+
attached.set(on, list);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
// the nearest cluster around it that the document prints; none, the start
|
|
75
|
+
const s = n.in.map((key) => sectionOf.get(key)).find((x) => x !== undefined);
|
|
76
|
+
if (!s) {
|
|
77
|
+
atStart.push(k);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const list = inSection.get(s) ?? [];
|
|
81
|
+
list.push(k);
|
|
82
|
+
inSection.set(s, list);
|
|
83
|
+
});
|
|
84
|
+
for (const [i, ks] of attached)
|
|
85
|
+
attached.set(i, boardOrder(ks, (k) => spots.notes[k].at));
|
|
86
|
+
const declAt = (i) => spots.decls.get(g.nodes[i].name) ?? null;
|
|
87
|
+
// a section's circle; where the board names none (a board laid out otherwise), the middle of what
|
|
88
|
+
// it prints
|
|
89
|
+
const sectionAt = (s) => {
|
|
90
|
+
const c = spots.clusters.get(s.key);
|
|
91
|
+
if (c)
|
|
92
|
+
return c;
|
|
93
|
+
let x = 0, y = 0, n = 0;
|
|
94
|
+
const sum = (t) => {
|
|
95
|
+
for (const i of t.decls) {
|
|
96
|
+
const p = declAt(i);
|
|
97
|
+
if (p)
|
|
98
|
+
[x, y, n] = [x + p[0], y + p[1], n + 1];
|
|
99
|
+
}
|
|
100
|
+
t.sections.forEach(sum);
|
|
101
|
+
};
|
|
102
|
+
sum(s);
|
|
103
|
+
return n ? [x / n, y / n] : null;
|
|
104
|
+
};
|
|
105
|
+
const at = (p) => (p.t === "decl" ? declAt(p.i) : p.t === "note" ? spots.notes[p.k].at : sectionAt(p.s.plan));
|
|
106
|
+
const section = (s) => ({
|
|
107
|
+
plan: s,
|
|
108
|
+
items: boardOrder([...s.decls.map((i) => ({ t: "decl", i })), ...(inSection.get(s) ?? []).map((k) => ({ t: "note", k })), ...s.sections.map((t) => ({ t: "section", s: section(t) }))], at),
|
|
109
|
+
});
|
|
110
|
+
// the start, then the top-level sections: the start has no heading of its own to come back to
|
|
111
|
+
const start = boardOrder([...plan.decls.map((i) => ({ t: "decl", i })), ...atStart.map((k) => ({ t: "note", k }))], at);
|
|
112
|
+
const sections = boardOrder(plan.sections.map((s) => ({ t: "section", s: section(s) })), at);
|
|
113
|
+
return { items: [...start, ...sections], attached };
|
|
114
|
+
}
|
|
115
|
+
/** The board of `set` (per node of `g`) by `mode` with the levels `hidden` hidden as a fresh layout
|
|
116
|
+
* draws it, with nothing pinned and no notes: what a board nobody arranged looks like, laid out on
|
|
117
|
+
* its own. What the builder orders by where it is told nothing of the board (shared/doc/build.ts). */
|
|
118
|
+
export function freshSpots(g, set, mode, hidden) {
|
|
119
|
+
const sub = subgraph(g, set).graph;
|
|
120
|
+
const L = boardLayout(sub, mode, "fresh", null, hidden).layout;
|
|
121
|
+
const decls = new Map();
|
|
122
|
+
sub.nodes.forEach((v, i) => decls.set(v.name, [L.x[i], L.y[i]]));
|
|
123
|
+
return { decls, clusters: new Map(L.clusters.map((c) => [c.key, [c.x, c.y]])), notes: [] };
|
|
124
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// The document's outline (ExportSpec decision 18, the linear form): what the document of a view
|
|
2
|
+
// prints, in its order, without the text — its sections, numbered as the PDF numbers them, the
|
|
3
|
+
// declarations and the notes in each, and the notes attached to a declaration after it. The page
|
|
4
|
+
// draws the board as this outline (web/src/linear.ts); it is made from the same plan and the same
|
|
5
|
+
// order the document is (shared/doc/sections.ts, shared/doc/order.ts), so that the two agree.
|
|
6
|
+
import { orderDoc } from "./order.js";
|
|
7
|
+
/** A note's first words: its first line, Markdown marks taken out, at most 60 characters. */
|
|
8
|
+
export function firstWords(text) {
|
|
9
|
+
const line = text.split("\n").find((l) => l.trim()) ?? "";
|
|
10
|
+
const plain = line.replace(/[*_`#>]+/g, "").replace(/\s+/g, " ").trim();
|
|
11
|
+
return plain.length > 60 ? `${plain.slice(0, 59).trimEnd()}…` : plain;
|
|
12
|
+
}
|
|
13
|
+
/** The outline of the document of `plan` (of graph `g`) in the order of the board `spots` gives. */
|
|
14
|
+
export function outlineOf(plan, g, spots) {
|
|
15
|
+
const order = orderDoc(plan, g, spots);
|
|
16
|
+
const note = (k) => {
|
|
17
|
+
const n = spots.notes[k];
|
|
18
|
+
return { t: "note", id: n.note.id, kind: n.kind, words: firstWords(n.note.text) };
|
|
19
|
+
};
|
|
20
|
+
const item = (p, prefix, at) => {
|
|
21
|
+
if (p.t === "note")
|
|
22
|
+
return note(p.k);
|
|
23
|
+
if (p.t === "decl") {
|
|
24
|
+
const ks = order.attached.get(p.i);
|
|
25
|
+
return { t: "decl", name: g.nodes[p.i].name, ...(ks?.length ? { notes: ks.map(note) } : {}) };
|
|
26
|
+
}
|
|
27
|
+
return section(p.s, prefix, at);
|
|
28
|
+
};
|
|
29
|
+
// numbered as the PDF numbers its headings (shared/doc/latex.ts): the first three levels, each
|
|
30
|
+
// within the one above; a paragraph-level heading has none
|
|
31
|
+
const section = (o, prefix, at) => {
|
|
32
|
+
const p = o.plan;
|
|
33
|
+
at.k++;
|
|
34
|
+
const number = p.level < 4 ? (prefix ? `${prefix}.${at.k}` : `${at.k}`) : null;
|
|
35
|
+
const inner = { k: 0 };
|
|
36
|
+
const items = o.items.map((x) => item(x, p.level < 3 ? (number ?? "") : "", inner));
|
|
37
|
+
return { t: "section", key: p.key, keys: [...p.keys], title: p.title, number, level: p.level, items };
|
|
38
|
+
};
|
|
39
|
+
const top = { k: 0 };
|
|
40
|
+
return {
|
|
41
|
+
items: order.items.map((x) => item(x, "", top)),
|
|
42
|
+
count: { declarations: plan.count, sections: plan.sectionCount },
|
|
43
|
+
...(spots.fresh ? { fresh: true } : {}),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
// Proofs are never exported (ExportSpec decision 8): a theorem's source is printed up to its
|
|
2
|
+
// statement, and `:= …` stands for the rest. Where its value starts is read off its text here.
|
|
3
|
+
//
|
|
4
|
+
// The usage pass does not record where a value starts (extractor/Usage.lean tells a value's
|
|
5
|
+
// syntax apart while it walks a command, but writes only the uses it found), so the text is read
|
|
6
|
+
// as the tokenizer reads it (shared/lexer.ts): comments, docstrings, strings and attributes are
|
|
7
|
+
// whole tokens and never searched, and brackets are followed, so that a `:=` inside a binder's
|
|
8
|
+
// default value or an attribute's argument is not the value's. The value starts at the first `:=`
|
|
9
|
+
// outside every bracket that no `let` or `have` in the statement claims, at a line that starts
|
|
10
|
+
// with `|` and has `=>` on it (equations, `theorem f : ∀ n, P n | 0 => …`), or at a `where`. A
|
|
11
|
+
// statement never holds a `by` outside brackets: one met before the value's start means the
|
|
12
|
+
// reading went wrong (a `have` claimed the wrong `:=`), and nothing is found. Where nothing is
|
|
13
|
+
// found, the caller prints the graph's statement instead: a proof is never printed for want of
|
|
14
|
+
// finding where it starts.
|
|
15
|
+
//
|
|
16
|
+
// Proofs inside a definition's value are left out too, and the rest of the definition is always
|
|
17
|
+
// printed (decided 2026-10-08). The usage pass records where they are (RawNode.proofs: `by` blocks
|
|
18
|
+
// whose goal is a proposition, the tactics of `decreasing_by`, term-mode proofs written in the
|
|
19
|
+
// text), and `cutProofs` puts `…` in the place of each. Those places are the compiled text's, so
|
|
20
|
+
// the caller uses them only for a text the pass read. Elsewhere — the file changed since it was
|
|
21
|
+
// compiled, or the pass is off, still running or failed on the module — `cutByText` finds them by
|
|
22
|
+
// reading the text: every `by` block in the value, and the tactics of `decreasing_by`. That reading
|
|
23
|
+
// cannot tell a `by` block that builds data from one that proves, and leaves out both; and it cannot
|
|
24
|
+
// find a proof written as a term, which it keeps.
|
|
25
|
+
import { tokenizeLean } from "../lexer.js";
|
|
26
|
+
const OPEN = "([{⦃⟨⟦⟪⦅⦇⁅";
|
|
27
|
+
const CLOSE = ")]}⦄⟩⟧⟫⦆⦈⁆";
|
|
28
|
+
/** words in a statement's terms that bind with a `:=` of their own */
|
|
29
|
+
const BINDS = new Set(["let", "have", "haveI", "letI"]);
|
|
30
|
+
/** Where the value of a declaration whose text is `text` starts (an offset into `text`), or null
|
|
31
|
+
* when it cannot be told: where a theorem's proof starts. */
|
|
32
|
+
export function valueStart(text) {
|
|
33
|
+
let depth = 0;
|
|
34
|
+
let claimed = 0;
|
|
35
|
+
for (const t of tokenizeLean(text)) {
|
|
36
|
+
if (t.kind === "comment" || t.kind === "doc" || t.kind === "string" || t.kind === "attr")
|
|
37
|
+
continue;
|
|
38
|
+
if (t.kind === "keyword" || t.kind === "ident") {
|
|
39
|
+
if (depth === 0 && BINDS.has(t.text))
|
|
40
|
+
claimed++;
|
|
41
|
+
else if (depth === 0 && t.text === "where")
|
|
42
|
+
return t.at;
|
|
43
|
+
else if (depth === 0 && t.text === "by")
|
|
44
|
+
return null;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (t.kind !== "text" && t.kind !== "op")
|
|
48
|
+
continue;
|
|
49
|
+
for (let k = 0; k < t.text.length; k++) {
|
|
50
|
+
const c = t.text[k];
|
|
51
|
+
const at = t.at + k;
|
|
52
|
+
if (OPEN.includes(c))
|
|
53
|
+
depth++;
|
|
54
|
+
else if (CLOSE.includes(c))
|
|
55
|
+
depth = Math.max(0, depth - 1);
|
|
56
|
+
else if (depth === 0 && c === ":" && t.text[k + 1] === "=") {
|
|
57
|
+
if (claimed > 0) {
|
|
58
|
+
claimed--;
|
|
59
|
+
k++;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
return at;
|
|
63
|
+
}
|
|
64
|
+
else if (depth === 0 && c === "|" && lineStart(text, at) && equation(text, at))
|
|
65
|
+
return at;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
/** Whether only spaces come before `at` on its line. */
|
|
71
|
+
function lineStart(text, at) {
|
|
72
|
+
const nl = text.lastIndexOf("\n", at - 1);
|
|
73
|
+
return nl >= 0 && /^[ \t]*$/.test(text.slice(nl + 1, at));
|
|
74
|
+
}
|
|
75
|
+
/** Whether the line from `at` is an equation's, `| pattern => value`: a `=>` on it. */
|
|
76
|
+
function equation(text, at) {
|
|
77
|
+
const end = text.indexOf("\n", at);
|
|
78
|
+
return text.slice(at, end < 0 ? text.length : end).includes("=>");
|
|
79
|
+
}
|
|
80
|
+
/** A theorem's text without its proof: up to where its value starts, the spaces before it left
|
|
81
|
+
* out; null where that place cannot be found. */
|
|
82
|
+
export function statementOnly(text) {
|
|
83
|
+
const at = valueStart(text);
|
|
84
|
+
return at === null ? null : text.slice(0, at).replace(/\s+$/, "");
|
|
85
|
+
}
|
|
86
|
+
/** The UTF-16 offset of code point `col` of `line` (its end where the line is shorter). */
|
|
87
|
+
function utf16At(line, col) {
|
|
88
|
+
let i = 0;
|
|
89
|
+
for (let cp = 0; cp < col && i < line.length; cp++)
|
|
90
|
+
i += (line.codePointAt(i) ?? 0) > 0xffff ? 2 : 1;
|
|
91
|
+
return i;
|
|
92
|
+
}
|
|
93
|
+
/** The file's lines `start`..`end` (0-based, inclusive) as highlighted code, each proof span in them
|
|
94
|
+
* cut and `…` (an `elided` token) put in its place: `field := by decide` reads `field := …`; a
|
|
95
|
+
* multi-line `by` block is `…` on its first line, the lines after it gone up to its end, where what
|
|
96
|
+
* follows the proof on its last line joins the first; a `by` in the middle of a term is `…` there.
|
|
97
|
+
* Everything else is kept as written, comments included, highlighted as the whole text is (the
|
|
98
|
+
* text is tokenized before it is cut). Spans outside the lines are ignored, one that runs past them
|
|
99
|
+
* is cut to their end. */
|
|
100
|
+
export function cutProofs(lines, start, end, spans) {
|
|
101
|
+
const own = lines.slice(start, end + 1);
|
|
102
|
+
const text = own.join("\n");
|
|
103
|
+
const lineStart = [];
|
|
104
|
+
let at = 0;
|
|
105
|
+
for (const l of own) {
|
|
106
|
+
lineStart.push(at);
|
|
107
|
+
at += l.length + 1;
|
|
108
|
+
}
|
|
109
|
+
const offset = (line, col) => {
|
|
110
|
+
const k = line - 1 - start;
|
|
111
|
+
if (k < 0)
|
|
112
|
+
return 0;
|
|
113
|
+
if (k >= own.length)
|
|
114
|
+
return text.length;
|
|
115
|
+
return lineStart[k] + utf16At(own[k], col);
|
|
116
|
+
};
|
|
117
|
+
return cutAt(text, spans.map((sp) => [offset(sp[0], sp[1]), offset(sp[2], sp[3])]));
|
|
118
|
+
}
|
|
119
|
+
/** A definition's text with the proofs in it left out where reading the text finds them
|
|
120
|
+
* (`textProofs`), cut as `cutProofs` cuts the usage pass's: for a text the pass has not read. */
|
|
121
|
+
export function cutByText(text) {
|
|
122
|
+
return cutAt(text, textProofs(text));
|
|
123
|
+
}
|
|
124
|
+
/** `text` as highlighted code with the spans in it (offsets, end exclusive) cut, `…` in the place of
|
|
125
|
+
* each. */
|
|
126
|
+
function cutAt(text, spans) {
|
|
127
|
+
// the spans sorted and apart (the pass merges them; a span given twice is cut once)
|
|
128
|
+
const cuts = [];
|
|
129
|
+
for (const s of spans
|
|
130
|
+
.map(([a, b]) => [a, b])
|
|
131
|
+
.filter(([a, b]) => a < b)
|
|
132
|
+
.sort((x, y) => x[0] - y[0])) {
|
|
133
|
+
const last = cuts[cuts.length - 1];
|
|
134
|
+
if (last && s[0] <= last[1])
|
|
135
|
+
last[1] = Math.max(last[1], s[1]);
|
|
136
|
+
else
|
|
137
|
+
cuts.push(s);
|
|
138
|
+
}
|
|
139
|
+
// the tokens outside the cuts, an `elided` one at each cut's start
|
|
140
|
+
const kept = [];
|
|
141
|
+
let ci = 0;
|
|
142
|
+
for (const t of tokenizeLean(text)) {
|
|
143
|
+
let p = t.at;
|
|
144
|
+
const e = t.at + t.text.length;
|
|
145
|
+
while (p < e) {
|
|
146
|
+
while (ci < cuts.length && cuts[ci][1] <= p)
|
|
147
|
+
ci++;
|
|
148
|
+
const c = cuts[ci];
|
|
149
|
+
if (!c || e <= c[0]) {
|
|
150
|
+
kept.push({ kind: t.kind, text: text.slice(p, e) });
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
153
|
+
if (p < c[0])
|
|
154
|
+
kept.push({ kind: t.kind, text: text.slice(p, c[0]) });
|
|
155
|
+
if (p <= c[0])
|
|
156
|
+
kept.push({ kind: "elided", text: "…" });
|
|
157
|
+
p = Math.min(e, c[1]);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
// the lines, a new one at each line break that was kept
|
|
161
|
+
const out = [[]];
|
|
162
|
+
for (const t of kept)
|
|
163
|
+
t.text.split("\n").forEach((part, k) => {
|
|
164
|
+
if (k)
|
|
165
|
+
out.push([]);
|
|
166
|
+
if (part)
|
|
167
|
+
out[out.length - 1].push([t.kind, part]);
|
|
168
|
+
});
|
|
169
|
+
return { lines: out, cuts: cuts.length };
|
|
170
|
+
}
|
|
171
|
+
/** The words that end a tactic block where they stand at its depth: a definition's termination
|
|
172
|
+
* clauses, its auxiliary definitions and its derived instances come after its value, and no
|
|
173
|
+
* tactic is one of them. */
|
|
174
|
+
const BLOCK_ENDS = new Set(["termination_by", "decreasing_by", "where", "deriving"]);
|
|
175
|
+
/** The pieces of `text`, in order. */
|
|
176
|
+
function pieces(text) {
|
|
177
|
+
const out = [];
|
|
178
|
+
let line = -1;
|
|
179
|
+
const push = (t, at, end) => {
|
|
180
|
+
const start = text.lastIndexOf("\n", at - 1) + 1;
|
|
181
|
+
out.push({ t, text: text.slice(at, end), at, end, quote: t === "open" && text[at - 1] === "`", col: start === line ? -1 : at - start });
|
|
182
|
+
line = start;
|
|
183
|
+
};
|
|
184
|
+
for (const t of tokenizeLean(text)) {
|
|
185
|
+
if (t.kind === "comment" || t.kind === "doc")
|
|
186
|
+
continue;
|
|
187
|
+
if (t.kind !== "text" && t.kind !== "op") {
|
|
188
|
+
push("word", t.at, t.at + t.text.length);
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
for (let k = 0; k < t.text.length;) {
|
|
192
|
+
const c = String.fromCodePoint(t.text.codePointAt(k));
|
|
193
|
+
const at = t.at + k;
|
|
194
|
+
k += c.length;
|
|
195
|
+
if (/\s/.test(c))
|
|
196
|
+
continue;
|
|
197
|
+
push(OPEN.includes(c) ? "open" : CLOSE.includes(c) ? "close" : c === "," ? "comma" : "mark", at, at + c.length);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return out;
|
|
201
|
+
}
|
|
202
|
+
/** Where the proofs in a definition's text are, read off the text alone (offsets, end exclusive):
|
|
203
|
+
* each `by` block in its value, and the tactics of `decreasing_by` (the keyword stays). A `by` in
|
|
204
|
+
* the signature is the statement's and stays, as the usage pass leaves it; where the value's start
|
|
205
|
+
* cannot be read, the whole text is searched. A `by` inside a quotation is a macro's meaning and
|
|
206
|
+
* stays. A `by` block that builds data is cut as well — the text cannot tell its goal — and a
|
|
207
|
+
* proof written as a term is not found at all. */
|
|
208
|
+
export function textProofs(text) {
|
|
209
|
+
const ps = pieces(text);
|
|
210
|
+
const from = valueStart(text) ?? 0;
|
|
211
|
+
const out = [];
|
|
212
|
+
// the brackets open where the reading is, each whether it is a quotation's
|
|
213
|
+
const open = [];
|
|
214
|
+
for (let k = 0; k < ps.length;) {
|
|
215
|
+
const p = ps[k];
|
|
216
|
+
if (p.t === "word" && (p.text === "by" || p.text === "decreasing_by") && p.at >= from && !open.includes(true)) {
|
|
217
|
+
const b = tacticBlock(text, ps, k, open.length > 0);
|
|
218
|
+
const start = p.text === "by" ? p.at : b.first;
|
|
219
|
+
if (start !== null && start < b.end)
|
|
220
|
+
out.push([start, b.end]);
|
|
221
|
+
k = b.next;
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
if (p.t === "open")
|
|
225
|
+
open.push(p.quote);
|
|
226
|
+
else if (p.t === "close")
|
|
227
|
+
open.pop();
|
|
228
|
+
k++;
|
|
229
|
+
}
|
|
230
|
+
return out;
|
|
231
|
+
}
|
|
232
|
+
/** The tactic block the `by` or `decreasing_by` at `ps[k]` opens, read as Lean lays it out: it runs
|
|
233
|
+
* to the first piece, at its own bracket depth, that closes the bracket it is in, or is a comma in
|
|
234
|
+
* that bracket (`enclosed`), a word of BLOCK_ENDS, a `then` or `else` of no `if` in the block, or
|
|
235
|
+
* the first of a line that starts no further right than the line the block starts on (its tactics'
|
|
236
|
+
* column less one, where they start on a line of their own further left than that). Where its last
|
|
237
|
+
* piece ends, where its first piece after the keyword starts (null where it has none), and the
|
|
238
|
+
* index of the first piece after it. */
|
|
239
|
+
function tacticBlock(text, ps, k, enclosed) {
|
|
240
|
+
const key = ps[k];
|
|
241
|
+
const lineStart = text.lastIndexOf("\n", key.at - 1) + 1;
|
|
242
|
+
let edge = /^[ \t]*/.exec(text.slice(lineStart))[0].length;
|
|
243
|
+
let depth = 0;
|
|
244
|
+
let ifs = 0;
|
|
245
|
+
let end = key.end;
|
|
246
|
+
let first = null;
|
|
247
|
+
let j = k + 1;
|
|
248
|
+
for (; j < ps.length; j++) {
|
|
249
|
+
const p = ps[j];
|
|
250
|
+
if (first === null && p.col >= 0 && p.col <= edge)
|
|
251
|
+
edge = p.col - 1;
|
|
252
|
+
if (depth === 0) {
|
|
253
|
+
if (p.t === "close" || (p.t === "comma" && enclosed) || (p.col >= 0 && p.col <= edge))
|
|
254
|
+
break;
|
|
255
|
+
if (p.t === "word") {
|
|
256
|
+
if (BLOCK_ENDS.has(p.text) || ((p.text === "then" || p.text === "else") && !ifs))
|
|
257
|
+
break;
|
|
258
|
+
if (p.text === "if")
|
|
259
|
+
ifs++;
|
|
260
|
+
else if (p.text === "else")
|
|
261
|
+
ifs--;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
if (p.t === "open")
|
|
265
|
+
depth++;
|
|
266
|
+
else if (p.t === "close")
|
|
267
|
+
depth--;
|
|
268
|
+
first ??= p.at;
|
|
269
|
+
end = p.end;
|
|
270
|
+
}
|
|
271
|
+
return { end, first, next: j };
|
|
272
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// The exported document's plan (ExportSpec decisions 12 and 13): what a view holds, as the document
|
|
2
|
+
// prints it. Every declaration of the project the board holds is printed, whatever is folded; what
|
|
3
|
+
// the view holds from outside the project is not, and is counted instead. The sections are the
|
|
4
|
+
// view's cluster structure as the board shows it — its clustering, its hidden levels taken out
|
|
5
|
+
// (ViewSpec decision 24; shared/layout.ts, clusterTree) — each cluster that holds something printed
|
|
6
|
+
// a section, nested by its depth.
|
|
7
|
+
//
|
|
8
|
+
// The plan says what is printed, not in which order: the order is the board's (the owner's decisions
|
|
9
|
+
// of 2026-10-12, shared/doc/order.ts), from where the view's board draws each thing, which only the
|
|
10
|
+
// server and a page that lays the board out know. The builder (shared/doc/build.ts) prints from it
|
|
11
|
+
// in that order, and the Export window (web/src/exportmodal.ts) says from it what the document will
|
|
12
|
+
// hold, so that the two agree.
|
|
13
|
+
import { clusterTree } from "../layout.js";
|
|
14
|
+
/** Heading levels: section, subsection, subsubsection, then a paragraph-level heading, which every
|
|
15
|
+
* deeper level takes too. */
|
|
16
|
+
export const HEADING_LEVELS = 4;
|
|
17
|
+
/** The most declarations one document prints: some 400 pages, typeset in well under the time
|
|
18
|
+
* XeLaTeX is given (server/export.ts). A larger view is narrowed first. */
|
|
19
|
+
export const MAX_DOC_DECLS = 2000;
|
|
20
|
+
/** Why a view of `count` declarations of the project makes no document, or null. */
|
|
21
|
+
export function tooMuch(count) {
|
|
22
|
+
if (!count)
|
|
23
|
+
return "This view holds no declaration of the project: there is nothing to print";
|
|
24
|
+
if (count > MAX_DOC_DECLS)
|
|
25
|
+
return `This view holds ${count.toLocaleString("en")} declarations of the project; a document prints at most ${MAX_DOC_DECLS.toLocaleString("en")}. Export a narrower view: a namespace or a folder, a target's Key view (key(T)) or the TCB of a target`;
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
/** The plan of the document of the board that holds `set` (per node of `g`) clustered by `mode`
|
|
29
|
+
* with the levels `hidden` hidden. A cluster that holds no declaration of its own and one section
|
|
30
|
+
* only is merged into it, its heading `A.B`, so that the document has no heading chains with
|
|
31
|
+
* nothing under them; the board merges such a chain into one circle already where it holds nothing
|
|
32
|
+
* else at all. On a board of a past build, `g` is its past graph (shared/pastgraph.ts), which holds
|
|
33
|
+
* that build's declarations alone. */
|
|
34
|
+
export function planDoc(g, set, mode, hidden) {
|
|
35
|
+
// the board's members in the whole graph's order, as a board's graph has them (shared/board.ts)
|
|
36
|
+
const members = [];
|
|
37
|
+
for (let i = 0; i < g.n; i++)
|
|
38
|
+
if (set[i])
|
|
39
|
+
members.push(i);
|
|
40
|
+
const tree = clusterTree(mode, members.map((i) => g.nodes[i]), hidden);
|
|
41
|
+
const own = tree.clusters.map(() => []);
|
|
42
|
+
const top = [];
|
|
43
|
+
const by = new Map();
|
|
44
|
+
let outside = 0;
|
|
45
|
+
members.forEach((i, k) => {
|
|
46
|
+
if (g.isExt(i)) {
|
|
47
|
+
outside++;
|
|
48
|
+
const lib = g.nodes[i].module.split(".")[0] || g.nodes[i].module;
|
|
49
|
+
by.set(lib, (by.get(lib) ?? 0) + 1);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const c = tree.cluster[k];
|
|
53
|
+
(c >= 0 ? own[c] : top).push(i);
|
|
54
|
+
});
|
|
55
|
+
// how much each cluster's subtree prints
|
|
56
|
+
const kids = tree.clusters.map(() => []);
|
|
57
|
+
const roots = [];
|
|
58
|
+
tree.clusters.forEach((c, k) => (c.parent >= 0 ? kids[c.parent] : roots).push(k));
|
|
59
|
+
const printed = new Int32Array(tree.clusters.length);
|
|
60
|
+
for (let k = tree.clusters.length - 1; k >= 0; k--) {
|
|
61
|
+
printed[k] += own[k].length;
|
|
62
|
+
const p = tree.clusters[k].parent;
|
|
63
|
+
if (p >= 0)
|
|
64
|
+
printed[p] += printed[k];
|
|
65
|
+
}
|
|
66
|
+
const kept = (k) => kids[k].filter((j) => printed[j] > 0);
|
|
67
|
+
let sectionCount = 0;
|
|
68
|
+
const section = (k, depth, above) => {
|
|
69
|
+
let title = tree.clusters[k].label;
|
|
70
|
+
const keys = [tree.clusters[k].key];
|
|
71
|
+
// nothing of its own and one section: one heading
|
|
72
|
+
for (let next = kept(k); !own[k].length && next.length === 1; next = kept(k)) {
|
|
73
|
+
k = next[0];
|
|
74
|
+
title = `${title}.${tree.clusters[k].label}`;
|
|
75
|
+
keys.push(tree.clusters[k].key);
|
|
76
|
+
}
|
|
77
|
+
sectionCount++;
|
|
78
|
+
const level = Math.min(depth, HEADING_LEVELS);
|
|
79
|
+
if (depth > HEADING_LEVELS && above !== null)
|
|
80
|
+
title = `${above}.${title}`;
|
|
81
|
+
const s = { key: tree.clusters[k].key, title, level, depth, keys, decls: own[k], sections: [] };
|
|
82
|
+
s.sections = kept(k).map((j) => section(j, depth + 1, depth >= HEADING_LEVELS ? title : null));
|
|
83
|
+
return s;
|
|
84
|
+
};
|
|
85
|
+
const sections = roots.filter((k) => printed[k] > 0).map((k) => section(k, 1, null));
|
|
86
|
+
return {
|
|
87
|
+
decls: top,
|
|
88
|
+
sections,
|
|
89
|
+
count: top.length + own.reduce((a, d) => a + d.length, 0),
|
|
90
|
+
sectionCount,
|
|
91
|
+
outside: { count: outside, by: [...by].map(([name, count]) => ({ name, count })).sort((a, b) => b.count - a.count || (a.name < b.name ? -1 : 1)) },
|
|
92
|
+
};
|
|
93
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// The exported document's settings (ExportSpec decision 7): every option a user can set, with its
|
|
2
|
+
// default, checked the same way wherever it comes from — the Export modal (web/src/exportmodal.ts),
|
|
3
|
+
// POST /api/export, the agents' endpoints. The modal sets the common ones; the rest keep their
|
|
4
|
+
// defaults. They are the document's style; what it holds is the view's (ExportSpec decision 12), and
|
|
5
|
+
// so are its title and its author line (decisions 14 and 15): no setting gives them.
|
|
6
|
+
// What a renderer does with them is its own business (shared/doc/latex.ts); the style file it loads
|
|
7
|
+
// can go further (ExportSpec.md, "Customising the style").
|
|
8
|
+
export const DEFAULT_SETTINGS = Object.freeze({
|
|
9
|
+
paper: "a4",
|
|
10
|
+
margin: 22,
|
|
11
|
+
fontSize: 11,
|
|
12
|
+
codeSize: 8.5,
|
|
13
|
+
contents: true,
|
|
14
|
+
});
|
|
15
|
+
/** Settings there were until 2026-10-08 (ExportSpec decision 7): the colour scheme, line numbers
|
|
16
|
+
* and the context (none is printed), and the key graph's figure (the document has none); until
|
|
17
|
+
* 2026-10-09, the appendix (`supportIndex`: the document has none), and the title and the author
|
|
18
|
+
* line (`title`, `author`: the view's title and the people who left a trace on it, decisions 14 and
|
|
19
|
+
* 15). A request that sends one is taken as before and the setting ignored, so that a page or an
|
|
20
|
+
* agent made earlier still exports. */
|
|
21
|
+
export const RETIRED_SETTINGS = ["scheme", "lineNumbers", "context", "figure", "supportIndex", "title", "author"];
|
|
22
|
+
/** What each setting is, for the agents' index and the docs. */
|
|
23
|
+
export const SETTINGS_HELP = {
|
|
24
|
+
paper: "a4 | letter",
|
|
25
|
+
margin: "millimetres on every side, 10–40",
|
|
26
|
+
fontSize: "the text's size in points: 10, 11 or 12",
|
|
27
|
+
codeSize: "the code's size in points, 6–12 in steps of 0.5",
|
|
28
|
+
contents: "true: a table of contents after the head card, in two columns (the sections, and the declarations where the document is short), the body following on the same page",
|
|
29
|
+
};
|
|
30
|
+
/** The settings a request asks for, over the defaults; or why they cannot be had. Unknown fields
|
|
31
|
+
* are refused, so that a misspelt option is said rather than ignored; a retired one is ignored. */
|
|
32
|
+
export function readSettings(raw) {
|
|
33
|
+
if (raw === undefined || raw === null)
|
|
34
|
+
return { settings: { ...DEFAULT_SETTINGS } };
|
|
35
|
+
if (typeof raw !== "object" || Array.isArray(raw))
|
|
36
|
+
return { error: "settings is an object of options" };
|
|
37
|
+
const o = raw;
|
|
38
|
+
const s = { ...DEFAULT_SETTINGS };
|
|
39
|
+
for (const [k, v] of Object.entries(o)) {
|
|
40
|
+
if (RETIRED_SETTINGS.includes(k))
|
|
41
|
+
continue;
|
|
42
|
+
if (!Object.hasOwn(DEFAULT_SETTINGS, k))
|
|
43
|
+
return { error: `${k} is not a setting (${Object.keys(DEFAULT_SETTINGS).join(", ")})` };
|
|
44
|
+
if (v === undefined)
|
|
45
|
+
continue;
|
|
46
|
+
switch (k) {
|
|
47
|
+
case "paper":
|
|
48
|
+
if (v !== "a4" && v !== "letter")
|
|
49
|
+
return { error: "paper is a4 or letter" };
|
|
50
|
+
s.paper = v;
|
|
51
|
+
break;
|
|
52
|
+
case "margin":
|
|
53
|
+
if (typeof v !== "number" || !Number.isFinite(v) || v < 10 || v > 40)
|
|
54
|
+
return { error: "margin is in millimetres, 10–40" };
|
|
55
|
+
s.margin = Math.round(v);
|
|
56
|
+
break;
|
|
57
|
+
case "fontSize":
|
|
58
|
+
if (v !== 10 && v !== 11 && v !== 12)
|
|
59
|
+
return { error: "fontSize is 10, 11 or 12" };
|
|
60
|
+
s.fontSize = v;
|
|
61
|
+
break;
|
|
62
|
+
case "codeSize":
|
|
63
|
+
if (typeof v !== "number" || !Number.isFinite(v) || v < 6 || v > 12)
|
|
64
|
+
return { error: "codeSize is in points, 6–12" };
|
|
65
|
+
s.codeSize = Math.round(v * 2) / 2;
|
|
66
|
+
break;
|
|
67
|
+
case "contents":
|
|
68
|
+
if (typeof v !== "boolean")
|
|
69
|
+
return { error: `${k} is true or false` };
|
|
70
|
+
s.contents = v;
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return { settings: s };
|
|
75
|
+
}
|