Ai slop
Some checks failed
Build and Deploy Nuxt / build (push) Failing after 41s

This commit is contained in:
2026-10-03 14:13:08 +02:00
parent 68b74eb60b
commit 6df7950161
45 changed files with 4594 additions and 125 deletions

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,252 @@
/* The Lean 4 language for Monaco — registered once, client-side.
*
* A Monarch tokenizer (keywords, tactics, commands, the nested `/- -/`
* comments Lean uses for docstrings) plus themes built from the wiki's live
* palette, so the editor matches the page around it and follows the wiki's
* theme *and* accent switches live: the accent — caret, selection, active
* line number, keywords — is sampled from the `--accent` custom properties
* that `[data-accent]` swaps. (Lean's own server also offers semantic
* highlighting; keyword colours are the honest first pass — see the note in
* lean/README.) */
import * as monaco from 'monaco-editor'
export const LEAN_LANGUAGE = 'lean4'
export const LEAN_THEMES = { light: 'mathew-light', dark: 'mathew-dark' } as const
/* words that introduce declarations, terms, and structuring */
const keywords = [
'abstract', 'axiom', 'by', 'cases', 'catch', 'class', 'command', 'deriving', 'do',
'else', 'end', 'example', 'expansion', 'explicit', 'export', 'def', 'theorem',
'lemma', 'corollary', 'proposition', 'instance', 'structure', 'inductive', 'abbrev',
'mutual', 'namespace', 'section', 'open', 'protected', 'private', 'partial',
'noncomputable', 'set_option', 'unset_option', 'option', 'attribute', 'variable',
'variables', 'include', 'hiding', 'renaming', 'as', 'where', 'from', 'show',
'have', 'suffices', 'let', 'in', 'if', 'then', 'match', 'with', 'without', 'fun',
'forall', 'at', 'using', 'via', 'override', 'obsolete', 'inherit', 'field',
'generalizing', 'replacing', 'renaming', 'specializing', 'unshuffling', 'case',
'dive', 'guard', 'modifies', 'macro', 'macro_rules', 'syntax', 'elab', 'elab_rules',
'notation', 'infix', 'infixl', 'infixr', 'prefix', 'postfix', 'binders',
'declare_syntax_cat', 'syntax_cat', 'extend', 'scoped', 'unused', 'auto_unfold',
'extern', 'implemented_by', 'native_decide', 'lift', 'coe', 'coe_struct',
'classical', 'decidable', 'universe', 'universes', 'exit',
]
/* tactics — the words after `by` and inside tactic blocks */
const tactics = [
'intro', 'intros', 'exact', 'apply', 'refine', 'have', 'obtain', 'use', 'exists',
'constructor', 'left', 'right', 'simp', 'simp_all', 'simp_rw', 'rw', 'rewrite',
'show', 'conv', 'calc', 'ring', 'ring_nf', 'field_simp', 'norm_num', 'omega',
'linarith', 'nlinarith', 'positivity', 'decide', 'trivial', 'rfl', 'refl', 'symm',
'trans', 'ext', 'funext', 'congr', 'congr!', 'cases', 'induction', 'rcases',
'obtain', 'subst', 'contradiction', 'exfalso', 'push_neg', 'contrapose',
'constructor', 'aesop', 'tauto', 'bv_decide', 'gcongr', 'change', 'generalize',
'revert', 'clear', 'rename_i', 'next', 'any_goals', 'all_goals', 'focus', 'dotl',
'by_contra', 'by_contra!', 'apply_fun', 'congr_arg', 'congrFun', 'congrArg',
'simpa', 'simpa using', 'norm_cast', 'exact?', 'apply?', 'sorry', 'native_decide',
'unfold', 'dsimp', 'unfold_projs', 'whisker', 'thm_to_tactic', 'tauto', 'mt',
'absurd', 'injection', 'no_index', 'guard_goals', 'expect_failure', 'stop', 'skip',
'trace', 'log', 'sleep', 'wait', 'recover', 'error',
]
const commands = ['#check', '#eval', '#reduce', '#synth', '#print', '#exit', '#where', '#hole', '#why', '#loogle', '#guard']
let registered = false
export function registerLeanLanguage() {
if (registered) return
registered = true
monaco.languages.register({ id: LEAN_LANGUAGE })
monaco.languages.setLanguageConfiguration(LEAN_LANGUAGE, {
comments: { lineComment: '--', blockComment: ['/-', '-/'] },
brackets: [
['{', '}'], ['[', ']'], ['(', ')'],
['⟨', '⟩'], ['⟦', '⟧'], ['‹', '›'], ['⦃', '⦄'], ['⁅', '⁆'], ['⌈', '⌉'], ['⌊', '⌋'],
],
autoClosingPairs: [
{ open: '{', close: '}' }, { open: '[', close: ']' }, { open: '(', close: ')' },
{ open: '"', close: '"' }, { open: '/-', close: '-/' },
{ open: '⟨', close: '⟩' }, { open: '⟦', close: '⟧' }, { open: '‹', close: '›' },
],
surroundingPairs: [
{ open: '{', close: '}' }, { open: '[', close: ']' }, { open: '(', close: ')' },
{ open: '"', close: '"' }, { open: '⟨', close: '⟩' }, { open: '⟦', close: '⟧' },
{ open: '‹', close: '›' },
],
indentationRules: {
increaseIndentPattern: /^\s*(\bby\b|\bdo\b|\bthen\b|\bcalc\b|\{[^}]*$|\[[^\]]*$|\([^)]*$)/,
decreaseIndentPattern: /^\s*(\bend\b|\}|\\|\)|\/-})/,
},
})
monaco.languages.setMonarchTokensProvider(LEAN_LANGUAGE, {
keywords,
tactics,
commands,
tokenizer: {
root: [
[/--.*$/, 'comment'],
[/\/-/, { token: 'comment', next: '@blockcomment' }],
[/"/, { token: 'string', next: '@string' }],
[/[0-9]+\.[0-9]+([eE][-+]?[0-9]+)?/, 'number.float'],
[/[0-9]+([eE][-+]?[0-9]+)?[nNzQqR]?/, 'number'],
[
// one identifier run (ascii word, or a unicode run — Lean names
// carry α, →, ≤, ⁻¹), then decided against the word lists
/[a-zA-Z_#][a-zA-Z0-9_?!'∆∇]*|[^\x00-\x7F][a-zA-Z0-9_?!'∆∇]*/,
{
cases: {
'@commands': 'keyword.command',
'@tactics': 'keyword.tactic',
'@keywords': 'keyword',
'@default': 'identifier',
},
},
],
[/^\s*@\[[^\]]*\]/, 'annotation'],
[/[→←↔∧∨¬∀∃≠≤≥<>!=+\-*/^|&~:;.,@#][=]?/, 'operator'],
],
blockcomment: [
[/\/-/, { token: 'comment', next: '@blockcomment' }],
[/-\//, { token: 'comment', next: '@pop' }],
[/[^\-\/]+/, 'comment'],
[/[\/-]/, 'comment'],
],
string: [
[/[^\\"]+/, 'string'],
[/\\./, 'string.escape'],
[/"/, { token: 'string', next: '@pop' }],
],
},
})
}
/* ---------- the wiki's palette, read live, as editor themes ---------- */
/* Monaco themes are plain hex, not CSS variables, so the accent is sampled
* off the document each time a theme is built — the same read the canvas
* surfaces make. The fallbacks are violet, the wiki's default accent. */
function wikiAccent(dark: boolean) {
const css = getComputedStyle(document.documentElement)
const at = (name: string, fallback: string) => css.getPropertyValue(name).trim() || fallback
return {
accent: at('--accent', dark ? '#7c8cff' : '#4f5fe8'),
strong: at('--accent-strong', dark ? '#99a5ff' : '#3d4dd6'),
second: at('--accent-2', dark ? '#38d3ee' : '#0ea5c9'),
}
}
/* `#rrggbbaa` — Monaco's translucent form, standing in for the wiki's
* `color-mix`. Shorthand hex is widened; anything else passes through. */
function alpha(hex: string, a: string) {
if (/^#[0-9a-f]{3}$/i.test(hex)) return `#${hex.slice(1).split('').map(c => c + c).join('')}${a}`
return /^#[0-9a-f]{6}$/i.test(hex) ? `${hex}${a}` : hex
}
/* token rules take hex without the leading `#` */
function ink(hex: string) {
return hex.replace(/^#/, '')
}
/* Themes are named by their palette (mathew-dark-fb7185…): a new accent
* yields a fresh theme rather than one Monaco may have cached under an old
* palette, and a reader cycling back reuses a definition already made. */
const definedThemes = new Set<string>()
/* the theme matching the wiki right now — [data-theme] picks the surfaces,
* [data-accent] the caret, selection, active line number and keywords */
export function currentLeanTheme(): string {
const dark = document.documentElement.getAttribute('data-theme') === 'dark'
const a = wikiAccent(dark)
const name = `${dark ? LEAN_THEMES.dark : LEAN_THEMES.light}-${ink(a.accent)}-${ink(a.strong)}-${ink(a.second)}`.toLowerCase()
if (definedThemes.has(name)) return name
definedThemes.add(name)
monaco.editor.defineTheme(name, dark
? {
base: 'vs-dark',
inherit: true,
rules: [
{ token: 'keyword', foreground: ink(a.strong) },
{ token: 'keyword.tactic', foreground: '38d3ee' },
{ token: 'keyword.command', foreground: ink(a.strong), fontStyle: 'bold' },
{ token: 'string', foreground: '3fce8b' },
{ token: 'number', foreground: 'f5a83c' },
{ token: 'comment', foreground: '6b7488', fontStyle: 'italic' },
{ token: 'annotation', foreground: '99a1b5' },
{ token: 'operator', foreground: '99a1b5' },
{ token: 'type.identifier', foreground: '38d3ee' },
],
colors: {
'editor.background': '#141826',
'editor.foreground': '#e7eaf3',
'editor.lineHighlightBackground': '#1a2030',
'editorLineNumber.foreground': '#6b7488',
'editorLineNumber.activeForeground': a.accent,
'editorCursor.foreground': a.accent,
'editor.selectionBackground': alpha(a.accent, '40'),
'editor.selectionHighlightBackground': alpha(a.accent, '1f'),
'editorIndentGuide.background1': '#232a3c',
'editorIndentGuide.activeBackground1': '#313a52',
'editorWhitespace.foreground': '#313a52',
'editorWidget.background': '#10131c',
'editorWidget.border': '#232a3c',
'editorSuggestWidget.selectedBackground': '#1a2030',
'editorHoverWidget.background': '#10131c',
'editorHoverWidget.border': '#232a3c',
'scrollbarSlider.background': '#e7eaf329',
'scrollbarSlider.hoverBackground': '#e7eaf347',
'scrollbarSlider.activeBackground': '#e7eaf366',
'editorError.foreground': '#f47085',
'editorWarning.foreground': '#f5a83c',
'editorInfo.foreground': a.second,
},
}
: {
base: 'vs',
inherit: true,
rules: [
{ token: 'keyword', foreground: ink(a.strong) },
{ token: 'keyword.tactic', foreground: '0891b2' },
{ token: 'keyword.command', foreground: ink(a.strong), fontStyle: 'bold' },
{ token: 'string', foreground: '189a5c' },
{ token: 'number', foreground: 'd97706' },
{ token: 'comment', foreground: '8a93a8', fontStyle: 'italic' },
{ token: 'annotation', foreground: '5b6478' },
{ token: 'operator', foreground: '5b6478' },
{ token: 'type.identifier', foreground: '0891b2' },
],
colors: {
'editor.background': '#ffffff',
'editor.foreground': '#1b2130',
'editor.lineHighlightBackground': '#eef1f7',
'editorLineNumber.foreground': '#8a93a8',
'editorLineNumber.activeForeground': a.accent,
'editorCursor.foreground': a.accent,
'editor.selectionBackground': alpha(a.accent, '33'),
'editor.selectionHighlightBackground': alpha(a.accent, '1a'),
'editorIndentGuide.background1': '#e2e6ef',
'editorIndentGuide.activeBackground1': '#cfd5e3',
'editorWhitespace.foreground': '#cfd5e3',
'editorWidget.background': '#ffffff',
'editorWidget.border': '#e2e6ef',
'editorSuggestWidget.selectedBackground': '#eef1f7',
'editorHoverWidget.background': '#ffffff',
'editorHoverWidget.border': '#e2e6ef',
'scrollbarSlider.background': '#1b213024',
'scrollbarSlider.hoverBackground': '#1b21304d',
'scrollbarSlider.activeBackground': '#1b213066',
'editorError.foreground': '#d1344b',
'editorWarning.foreground': '#d97706',
'editorInfo.foreground': a.second,
},
})
return name
}
/* move the editor to the palette the wiki is wearing right now */
export function applyLeanTheme() {
monaco.editor.setTheme(currentLeanTheme())
}

View File

@@ -0,0 +1,116 @@
/* A very small JSON-RPC 2.0 client over WebSocket — the dialect the lean4web
* server forwards between the browser and a real Lean language server
* (`lake serve`): one JSON message per frame, in either direction.
*
* The wiki is a deliberate minimal client: it opens one document, tracks the
* cursor, and asks for goals. The Lean server nonetheless sends a few
* *requests* its way — registering file watchers, asking to refresh caches,
* probing for configuration. There is nothing to register or refresh here,
* so those are acknowledged with `null` (a `workspace/configuration` probe
* with `null` per item) rather than left hanging. */
export interface RpcError {
code: number
message: string
data?: unknown
}
interface RpcMessage {
jsonrpc: '2.0'
id?: number | string
method?: string
params?: unknown
result?: unknown
error?: RpcError
}
export class RpcSocket {
private ws: WebSocket
private nextId = 1
private pending = new Map<number, { resolve: (v: unknown) => void, reject: (e: Error) => void }>()
private closed = false
readonly whenOpen: Promise<void>
constructor(
url: string,
private handlers: {
onNotification?: (method: string, params: unknown) => void
onClose?: (info: { clean: boolean }) => void
} = {},
) {
this.ws = new WebSocket(url)
this.whenOpen = new Promise((resolve, reject) => {
this.ws.addEventListener('open', () => resolve(), { once: true })
this.ws.addEventListener('error', () => reject(new Error('websocket failed to open')), { once: true })
})
this.ws.addEventListener('message', (ev: MessageEvent) => {
let msg: RpcMessage
try {
msg = JSON.parse(String(ev.data))
}
catch { return }
if (!msg || typeof msg !== 'object' || msg.jsonrpc !== '2.0') return
if (msg.id !== undefined && msg.method) { this.answerServer(msg); return }
if (msg.id !== undefined) {
const waiter = this.pending.get(Number(msg.id))
if (!waiter) return
this.pending.delete(Number(msg.id))
if (msg.error) waiter.reject(new Error(`${msg.method ?? 'request'}: ${msg.error.message}`))
else waiter.resolve(msg.result)
return
}
if (msg.method) this.handlers.onNotification?.(msg.method, msg.params)
})
this.ws.addEventListener('close', (ev: CloseEvent) => {
this.closed = true
const err = new Error('lean server closed the connection')
for (const waiter of this.pending.values()) waiter.reject(err)
this.pending.clear()
this.handlers.onClose?.({ clean: ev.wasClean })
})
}
get open() {
return this.ws.readyState === WebSocket.OPEN
}
async request<T>(method: string, params?: unknown): Promise<T> {
if (this.closed || this.ws.readyState !== WebSocket.OPEN) throw new Error('not connected to the lean server')
const id = this.nextId++
const wire: RpcMessage = { jsonrpc: '2.0', id, method }
if (params !== undefined) wire.params = params
this.ws.send(JSON.stringify(wire))
return new Promise<T>((resolve, reject) => {
this.pending.set(id, { resolve: resolve as (v: unknown) => void, reject })
})
}
notify(method: string, params?: unknown) {
if (this.closed || this.ws.readyState !== WebSocket.OPEN) return
const wire: RpcMessage = { jsonrpc: '2.0', method }
if (params !== undefined) wire.params = params
this.ws.send(JSON.stringify(wire))
}
close() {
this.closed = true
try { this.ws.close() }
catch { /* already gone */ }
}
/* capability registrations, cache refreshes, configuration probes: the
* honest answer from a single-document client is "nothing to do" */
private answerServer(msg: RpcMessage) {
let result: unknown = null
if (msg.method === 'workspace/configuration') {
const items = (msg.params as { items?: unknown[] } | undefined)?.items
result = Array.isArray(items) ? items.map(() => null) : null
}
if (this.closed || this.ws.readyState !== WebSocket.OPEN) return
this.ws.send(JSON.stringify({ jsonrpc: '2.0', id: msg.id, result }))
}
}

View File

@@ -0,0 +1,8 @@
/* Monaco's editor worker, wrapped so the `?worker` import stays static
* (what this toolchain resolves) while the component still pulls it in
* lazily, only in the browser. Monaco 0.57's exports map makes paths like
* `monaco-editor/esm/vs/...` invalid — the package-root form below is the
* real specifier (`./editor/*` → `./esm/vs/editor/*`). */
import EditorWorker from 'monaco-editor/editor/editor.worker.js?worker'
export { EditorWorker }

View File

@@ -10,6 +10,14 @@
everything it accepts is a real function of x or a real relation in the
plane, and everything else is refused with a reason.
A name that is not x, y, one of the constants, or a called function is a
free variable — "k", "a", "radius" — and asks for a slider: the compiled
answer names the variables it speaks, and evaluation reads their values
from the scope it is handed. So an expression with a variable means the
family of curves those values trace. "k" alone, or "k = 2" ahead of a
plain number, is the declaration itself: it claims the slider and plots
nothing of its own.
Precedence is the usual one: + - bind loosest; then * / % (left, implicit
multiplication included — "2x" is 2·x); then unary minus, so -x^2 negates
the square; then ^, right-associative, its exponent may carry a sign so
@@ -20,24 +28,39 @@ export type PlotFunction = (x: number) => number
/* a relation of the plane: the curve is its zero set */
export type PlaneFunction = (x: number, y: number) => number
/* a surface of space: the height z above the point (x, y) */
export type SurfaceFunction = (x: number, y: number) => number
/* the values a free name answers with — one slider per variable. A name the
scope does not carry yet reads as 1, where a fresh slider starts */
export type VarScope = Record<string, number>
/* why a text was refused: nothing to read, a name the calculator does not
speak, or a shape the grammar rejects */
export type PlotErrorReason = 'empty' | 'unknown' | 'syntax'
export type CompiledPlot =
| { ok: true; kind: 'function'; fn: PlotFunction }
| { ok: true; kind: 'relation'; fn: PlaneFunction }
// "(x, y)" or "P = (x, y)": a single dot at fixed coordinates, optionally named
| { ok: true; kind: 'point'; x: number; y: number; label: string | null }
// each kind carries the free variables it speaks, in order of appearance
| { ok: true; kind: 'function'; vars: string[]; fn: PlotFunction }
| { ok: true; kind: 'relation'; vars: string[]; fn: PlaneFunction }
// "(x, y)" or "P = (x, y)": a single dot at fixed coordinates, optionally
// named. Coordinates may ride the sliders, so the dot moves with them
| { ok: true; kind: 'point'; vars: string[]; x: number; y: number; label: string | null }
// "k" or "k = 2": claims a slider. A null value says the text asked for
// one without saying where it starts. Plots nothing of its own
| { ok: true; kind: 'variable'; vars: string[]; name: string; value: number | null }
| { ok: false; reason: PlotErrorReason }
type Op = '+' | '-' | '*' | '/' | '%' | '^' | '=' | '(' | ')' | ','
type Token = { t: Op } | { t: 'num'; value: number } | { t: 'id'; name: string }
/* a number carries the text it was read from: the typesetter prints that,
so a half-typed decimal keeps the digits exactly as they stand */
type Token = { t: Op } | { t: 'num'; value: number; raw: string } | { t: 'id'; name: string }
type Node =
| { kind: 'num'; value: number }
| { kind: 'var'; name: 'x' | 'y' }
| { kind: 'num'; value: number; raw?: string }
// x and y are the plane's coordinates; every other name is a slider
| { kind: 'var'; name: string }
| { kind: 'const'; value: number }
| { kind: 'neg'; arg: Node }
| { kind: 'bin'; op: '+' | '-' | '*' | '/' | '%' | '^'; a: Node; b: Node }
@@ -132,9 +155,10 @@ function tokenize(src: string): Token[] {
i++
} else break
}
const value = Number.parseFloat(s.slice(start, i))
const text = s.slice(start, i)
const value = Number.parseFloat(text)
if (!Number.isFinite(value)) throw new PlotParseError('syntax')
toks.push({ t: 'num', value })
toks.push({ t: 'num', value, raw: text })
continue
}
if (/[a-z_]/i.test(c)) {
@@ -171,6 +195,9 @@ function tokenize(src: string): Token[] {
interface Parser {
toks: Token[]
pos: number
/* when set, a name that is not x, y, a constant or a called function
becomes a variable recorded here; when absent, it is refused */
free: Set<string> | null
}
const peek = (p: Parser): Token | null => p.toks[p.pos] ?? null
@@ -238,7 +265,7 @@ function parseAtom(p: Parser): Node {
if (!tk) throw new PlotParseError('syntax')
if (tk.t === 'num') {
p.pos++
return { kind: 'num', value: tk.value }
return { kind: 'num', value: tk.value, raw: tk.raw }
}
if (tk.t === '(') {
p.pos++
@@ -268,28 +295,39 @@ function parseAtom(p: Parser): Node {
if (!arityOk(tk.name, args.length)) throw new PlotParseError('syntax')
return { kind: 'call', name: tk.name, args }
}
// no coordinate, no constant, no name the calculator calls — then it is
// a variable: a slider the reader sets, shared wherever its name is spoken
if (p.free) {
p.free.add(tk.name)
return { kind: 'var', name: tk.name }
}
throw new PlotParseError('unknown')
}
throw new PlotParseError('syntax')
}
function evaluate(node: Node, x: number, y: number): number {
function evaluate(node: Node, x: number, y: number, scope: VarScope): number {
switch (node.kind) {
case 'num':
return node.value
case 'var':
return node.name === 'x' ? x : y
case 'var': {
if (node.name === 'x') return x
if (node.name === 'y') return y
// a slider's set value; until it carries one, where every slider starts
const v = scope[node.name]
return typeof v === 'number' && Number.isFinite(v) ? v : 1
}
case 'const':
return node.value
case 'neg':
return -evaluate(node.arg, x, y)
return -evaluate(node.arg, x, y, scope)
case 'call': {
const fn = FUNCTIONS[node.name]!
return fn(...node.args.map((a) => evaluate(a, x, y)))
return fn(...node.args.map((a) => evaluate(a, x, y, scope)))
}
case 'bin': {
const a = evaluate(node.a, x, y)
const b = evaluate(node.b, x, y)
const a = evaluate(node.a, x, y, scope)
const b = evaluate(node.b, x, y, scope)
switch (node.op) {
case '+':
return a + b
@@ -326,9 +364,26 @@ function mentionsY(node: Node): boolean {
}
}
/* a point's coordinates hold no variable — a lone x or y has no single value
to place a dot at, so only constant expressions (numbers, pi, sqrt(2)…)
make a coordinate */
/* whether x or y stands anywhere in a tree. A point's coordinate may ride
the sliders — each has one value to place the dot at — but a plane
variable has no single value, so the dot would have nowhere to go */
function mentionsPlane(node: Node): boolean {
switch (node.kind) {
case 'var':
return node.name === 'x' || node.name === 'y'
case 'neg':
return mentionsPlane(node.arg)
case 'bin':
return mentionsPlane(node.a) || mentionsPlane(node.b)
case 'call':
return node.args.some(mentionsPlane)
default:
return false
}
}
/* a declaration's value is a plain number — numbers, pi, sqrt(2)… welcome,
sliders and x and y not */
function isConstant(node: Node): boolean {
switch (node.kind) {
case 'var':
@@ -377,62 +432,317 @@ function pointPair(toks: Token[]): [Token[], Token[]] | null {
}
/* one run of tokens, the whole of it — a leftover is a shape the grammar
does not know */
function parseAll(toks: Token[]): Node {
const p: Parser = { toks, pos: 0 }
does not know. Free variables are collected into `free` when given */
function parseAll(toks: Token[], free: Set<string> | null = null): Node {
const p: Parser = { toks, pos: 0, free }
const ast = parseExpr(p)
if (p.pos !== toks.length) throw new PlotParseError('syntax')
return ast
}
/* read one expression, equation, or point; a refusal says whether a name was
not known or the shape was wrong — an empty text is the caller's to treat as
"nothing asked for", not as an error. A "(x, y)" — or "P = (x, y)" with a
name out front — is a point at fixed coordinates. With an "=" otherwise the
text is the relation left − right = 0 (a lone "y =" ahead of an expression
in x is still the fast function); without it, an expression in x is y = f(x)
and one carrying y is the relation that expression equals zero. */
export function compilePlotExpression(src: string): CompiledPlot {
/* a name the reader may claim for a slider: not one of the plane's two
coordinates, not one of the constants, not a function the calculator calls */
function declName(name: string): string | null {
if (name === 'x' || name === 'y') return null
if (Object.hasOwn(CONSTANTS, name) || Object.hasOwn(FUNCTIONS, name)) return null
return name
}
/* read a slider's claim from a token run — "k" alone, or "k = 2" with a
claimable name ahead of a plain number — or null when the text claims
none. The value reads strictly: a "P = (2, 3)" or "k = x" is a point and
a relation, not declarations, and says so by not parsing as a number. */
function declarationOf(toks: Token[]): { name: string; value: number | null } | null {
if (toks.length === 0 || toks[0]!.t !== 'id') return null
const name = declName(toks[0]!.name)
if (!name) return null
if (toks.length === 1) return { name, value: null }
if (toks.length > 2 && toks[1]!.t === '=' && !toks.slice(2).some((tk) => tk.t === '=')) {
try {
const rhs = parseAll(toks.slice(2)) // strict: a declaration's value is a plain number
if (isConstant(rhs)) return { name, value: evaluate(rhs, 0, 0, {}) }
} catch {
/* not a plain number — not a declaration, whatever it reads as instead */
}
}
return null
}
/* read one expression, equation, point or slider declaration; a refusal says
whether a name was not known or the shape was wrong — an empty text is the
caller's to treat as "nothing asked for", not as an error. A "(x, y)" — or
"P = (x, y)" with a name out front — is a point at fixed coordinates. With
an "=" otherwise the text is the relation left − right = 0 (a lone "y ="
ahead of an expression in x is still the fast function); without it, an
expression in x is y = f(x) and one carrying y is the relation that
expression equals zero. A name none of those readings speaks — "k" alone,
or "k = 2" ahead of a plain number — declares a slider: it plots nothing
and its value answers everywhere the name is spoken. Functions, relations
and points list the variables they speak in `vars` and read their values
from `scope` at their every evaluation — so one dragged slider moves the
whole family of curves at once. */
export function compilePlotExpression(src: string, scope: VarScope = {}): CompiledPlot {
if (src.length > 400) return { ok: false, reason: 'syntax' }
try {
const toks = tokenize(stripAssignment(src))
if (toks.length === 0) return { ok: false, reason: 'empty' }
const at = toks.findIndex((tk) => tk.t === '=')
const free = new Set<string>()
// A slider claiming its own row: "k", or "k = 2" ahead of a plain number
const decl = declarationOf(toks)
if (decl) return { ok: true, kind: 'variable', vars: [decl.name], name: decl.name, value: decl.value }
// A point: "(x, y)" on its own, or "P = (x, y)" with a lone name ahead of
// the "=". Coordinates must be constant — a variable has no single value
// to place a dot at — and finite once read.
// the "=". Coordinates may ride the sliders — and must be finite once read.
const named = at === 1 && toks[0]!.t === 'id'
const pair = pointPair(named ? toks.slice(2) : toks)
if (pair) {
const cx = parseAll(pair[0])
const cy = parseAll(pair[1])
if (!isConstant(cx) || !isConstant(cy)) return { ok: false, reason: 'syntax' }
const x = evaluate(cx, 0, 0)
const y = evaluate(cy, 0, 0)
const cx = parseAll(pair[0], free)
const cy = parseAll(pair[1], free)
if (mentionsPlane(cx) || mentionsPlane(cy)) return { ok: false, reason: 'syntax' }
const x = evaluate(cx, 0, 0, scope)
const y = evaluate(cy, 0, 0, scope)
if (!Number.isFinite(x) || !Number.isFinite(y)) return { ok: false, reason: 'syntax' }
const label = named ? src.slice(0, src.indexOf('=')).trim() : null
return { ok: true, kind: 'point', x, y, label: label || null }
return { ok: true, kind: 'point', vars: [...free], x, y, label: label || null }
}
if (at === -1) {
const ast = parseAll(toks)
const ast = parseAll(toks, free)
return mentionsY(ast)
? { ok: true, kind: 'relation', fn: (x, y) => evaluate(ast, x, y) }
: { ok: true, kind: 'function', fn: (x) => evaluate(ast, x, 0) }
? { ok: true, kind: 'relation', vars: [...free], fn: (x, y) => evaluate(ast, x, y, scope) }
: { ok: true, kind: 'function', vars: [...free], fn: (x) => evaluate(ast, x, 0, scope) }
}
const left = toks.slice(0, at)
const right = toks.slice(at + 1)
if (left.length === 0 || right.length === 0 || right.some((tk) => tk.t === '='))
throw new PlotParseError('syntax')
const rhs = parseAll(right)
const rhs = parseAll(right, free)
// "y = <only x>" is the explicit curve it always was: x alone decides it
if (left.length === 1 && left[0]!.t === 'id' && left[0]!.name === 'y' && !mentionsY(rhs))
return { ok: true, kind: 'function', fn: (x) => evaluate(rhs, x, 0) }
const lhs = parseAll(left)
return { ok: true, kind: 'relation', fn: (x, y) => evaluate(lhs, x, y) - evaluate(rhs, x, y) }
return { ok: true, kind: 'function', vars: [...free], fn: (x) => evaluate(rhs, x, 0, scope) }
const lhs = parseAll(left, free)
return {
ok: true,
kind: 'relation',
vars: [...free],
fn: (x, y) => evaluate(lhs, x, y, scope) - evaluate(rhs, x, y, scope),
}
} catch (err) {
if (err instanceof PlotParseError) return { ok: false, reason: err.reason }
return { ok: false, reason: 'syntax' }
}
}
/* the inside of the 3D field's "z = …" — a lone "z =" ahead of an expression
in x and y; the reader may also write "f(x, y) =" or type the height with
no left side at all, which reads the same */
function stripSurfaceAssignment(src: string): string {
return src
.replace(/^\s*f\s*\(\s*x\s*,\s*y\s*\)\s*=/i, '')
.replace(/^\s*z\s*=/i, '')
}
export type CompiledSurface =
// each carries the free variables it speaks, in order of appearance
| { ok: true; kind: 'surface'; vars: string[]; fn: SurfaceFunction }
// "k" or "k = 2": claims a slider, as in the plane's fields. A null value
// says the text asked for one without saying where it starts. Draws nothing
| { ok: true; kind: 'variable'; vars: string[]; name: string; value: number | null }
| { ok: false; reason: PlotErrorReason }
/* compile the height of a surface above (x, y): the same algebra as the
plane, only read as z = f(x, y). A "z =" ahead of the expression is the
height's own name and sheds; what is left must speak x and y (constants
are welcome — a level surface is still a surface). Names x and y are not
are slider variables read from scope at each evaluation, and "k" or
"k = 2" is the plane's slider declaration — space and plane wear the very
same variables. A stray "=" is still a leftover: space answers a height
alone, and refuses whatever cannot read as one. */
export function compileSurfaceExpression(src: string, scope: VarScope = {}): CompiledSurface {
if (src.length > 400) return { ok: false, reason: 'syntax' }
try {
const body = stripSurfaceAssignment(src)
const toks = tokenize(body)
if (toks.length === 0) return { ok: false, reason: 'empty' }
// a slider claiming its own row: "k", or "k = 2" ahead of a plain number
const decl = declarationOf(toks)
if (decl) return { ok: true, kind: 'variable', vars: [decl.name], name: decl.name, value: decl.value }
const free = new Set<string>()
const ast = parseAll(toks, free)
return { ok: true, kind: 'surface', vars: [...free], fn: (x, y) => evaluate(ast, x, y, scope) }
} catch (err) {
if (err instanceof PlotParseError) return { ok: false, reason: err.reason }
return { ok: false, reason: 'syntax' }
}
}
/* ---------- printing the same maths back typeset ---------- */
/* the calculator's fields echo their text as set mathematics while it is
typed (Desmos' way). Printing the parser's own tree keeps the two
readings honest: what is displayed is exactly what would be plotted —
so a text that does not compile simply has no type-setting, and an
unfinished draft stays plain text until it reads right. Fractions are
fractions, radicals are radicals, exponents rise; parentheses appear
only where precedence would otherwise lie. */
const LATEX_FUNCTIONS: Record<string, string> = {
sin: '\\sin',
cos: '\\cos',
tan: '\\tan',
asin: '\\arcsin',
acos: '\\arccos',
atan: '\\arctan',
sinh: '\\sinh',
cosh: '\\cosh',
tanh: '\\tanh',
ln: '\\ln',
log: '\\log',
log2: '\\log_{2}',
log10: '\\log_{10}',
min: '\\min',
max: '\\max',
}
/* a folded constant answers as its symbol when its value is exactly one */
function constSymbol(v: number): string | null {
if (v === Math.PI) return '\\pi'
if (v === Math.PI * 2) return '\\tau'
if (v === Math.E) return 'e'
if (v === (1 + Math.sqrt(5)) / 2) return '\\varphi'
return null
}
/* how tightly a printed node binds: one that binds looser than the context
it stands in gains parentheses */
function nodePrec(n: Node): number {
if (n.kind === 'bin') {
if (n.op === '+' || n.op === '-') return 10
if (n.op === '^') return 40
if (n.op === '/') return 90 // a \\frac carries its own grouping
return 20 // * and %
}
if (n.kind === 'neg') return 30
return 100
}
function printNode(n: Node, ctx: number): string {
const s = printBare(n)
return nodePrec(n) < ctx ? `\\left(${s}\\right)` : s
}
/* two factors standing side by side are multiplied silently — unless the
right one is a number (numbers must not run together), or a bare number
is followed by a fraction (which would read as a mixed number) */
function needsDot(a: Node, b: Node): boolean {
if (b.kind === 'num') return true
return a.kind === 'num' && b.kind === 'bin' && b.op === '/'
}
function printBare(n: Node): string {
switch (n.kind) {
case 'num':
return n.raw ?? String(n.value)
case 'var':
return n.name
case 'const':
return constSymbol(n.value) ?? String(n.value)
case 'neg':
return `-${printNode(n.arg, 30)}`
case 'call':
return printCall(n.name, n.args)
case 'bin': {
switch (n.op) {
case '+':
return `${printNode(n.a, 10)} + ${printNode(n.b, 11)}`
case '-':
return `${printNode(n.a, 10)} - ${printNode(n.b, 11)}`
case '*': {
const a = printNode(n.a, 20)
// a looser factor beside a silent product would change its sense —
// even a leading minus reads as subtraction there. The gap between
// factors is a space: free in math mode, and it keeps a \\pi from
// running into the next atom as one control word
const b = printNode(n.b, 31)
return needsDot(n.a, n.b) ? `${a} \\cdot ${b}` : `${a} ${b}`
}
case '/':
return `\\frac{${printNode(n.a, 0)}}{${printNode(n.b, 0)}}`
case '%':
return `${printNode(n.a, 20)} \\bmod ${printNode(n.b, 21)}`
case '^':
// braces group the exponent outright, so it never needs parentheses;
// an exp(...) base is already a superscribed e — it takes parens
// rather than carry a second superscript
return `${n.a.kind === 'call' && n.a.name === 'exp' ? `\\left(${printBare(n.a)}\\right)` : printNode(n.a, 41)}^{${printNode(n.b, 0)}}`
}
}
}
}
function printCall(name: string, args: Node[]): string {
const one = args[0] ?? { kind: 'num', value: 0 }
switch (name) {
case 'sqrt':
return `\\sqrt{${printNode(one, 0)}}`
case 'cbrt':
return `\\sqrt[3]{${printNode(one, 0)}}`
case 'abs':
return `\\left|${printNode(one, 0)}\\right|`
case 'floor':
return `\\left\\lfloor ${printNode(one, 0)} \\right\\rfloor`
case 'ceil':
return `\\left\\lceil ${printNode(one, 0)} \\right\\rceil`
case 'exp':
return `e^{${printNode(one, 0)}}`
case 'mod':
return `${printNode(args[0] ?? one, 20)} \\bmod ${printNode(args[1] ?? one, 21)}`
default: {
const label = LATEX_FUNCTIONS[name] ?? name
return `${label}\\left(${args.map((a) => printNode(a, 0)).join(',\\, ')}\\right)`
}
}
}
/* the whole text of a field as one typeset line: the same readings the
compiler runs — an "f(x) =" or "z =" ahead of the maths is shed, a lone
"y =" the field's gutter already wears is shed too (the gutter prints
it), a "(x, y)" — optionally named — is a point, a claimable name alone
or ahead of a plain number claims a slider, and any other "=" is the
equation it states. Free variables print as themselves, italic like the
rest. Null when the text does not read: the caller keeps the plain text,
and its red, until it does. */
export function expressionToLatex(src: string): string | null {
if (!src.trim() || src.length > 400) return null
try {
const toks = tokenize(stripSurfaceAssignment(stripAssignment(src)))
if (toks.length === 0) return null
const at = toks.findIndex((tk) => tk.t === '=')
const free = new Set<string>()
const named = at === 1 && toks[0]!.t === 'id'
const pair = pointPair(named ? toks.slice(2) : toks)
if (pair) {
const coords = `\\left(${printNode(parseAll(pair[0], free), 0)},\\, ${printNode(parseAll(pair[1], free), 0)}\\right)`
if (!named) return coords
const label = src.slice(0, src.indexOf('=')).trim()
const name = /^[a-z][a-z0-9_]*$/i.test(label) ? label : `\\text{${label.replace(/[\\{}]/g, '')}}`
return `${name} = ${coords}`
}
if (at === -1) return printNode(parseAll(toks, free), 0)
const left = toks.slice(0, at)
const right = toks.slice(at + 1)
if (left.length === 0 || right.length === 0 || right.some((tk) => tk.t === '=')) return null
// "y = <only x>" is the function the field's gutter already wears: the
// type-set line answers with its right side alone
if (left.length === 1 && left[0]!.t === 'id' && left[0]!.name === 'y' && !right.some((tk) => tk.t === 'id' && tk.name === 'y'))
return printNode(parseAll(right, free), 0)
// "=" binds loosest of all — neither side ever needs parentheses
return `${printNode(parseAll(left, free), 0)} = ${printNode(parseAll(right, free), 0)}`
} catch {
return null
}
}

View File

@@ -67,17 +67,19 @@ export function isReadingSurface(path: string): boolean {
/* ---------- the wiki's own pages ----------
The link map and the graphing calculator are pages the shelf holds no
copies of — but the book can read them too. Each has one plain address of
its own, and useArticleTabs can lay it on the row beside the open articles:
the page then draws the live surface instead of an article, for as long as
its page stays open. */
The link map, the calculators and the Lean editor are pages the shelf
holds no copies of — but the book can read them too. Each has one plain
address of its own, and useArticleTabs can lay it on the row beside the
open articles: the page then draws the live surface instead of an
article, for as long as its page stays open. */
export type ToolPage = 'graph' | 'calculator'
export type ToolPage = 'graph' | 'calculator' | 'calculator3d' | 'lean'
const TOOL_PAGE_PATHS: Record<ToolPage, string> = {
graph: '/graph',
calculator: '/calculator',
calculator3d: '/calculator3d',
lean: '/lean',
}
/** The address a tool page lives at. */