This commit is contained in:
4
frontend/app/utils/lean/abbreviations.ts
Normal file
4
frontend/app/utils/lean/abbreviations.ts
Normal file
File diff suppressed because one or more lines are too long
252
frontend/app/utils/lean/lean-language.ts
Normal file
252
frontend/app/utils/lean/lean-language.ts
Normal 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())
|
||||
}
|
||||
116
frontend/app/utils/lean/lsp.ts
Normal file
116
frontend/app/utils/lean/lsp.ts
Normal 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 }))
|
||||
}
|
||||
}
|
||||
8
frontend/app/utils/lean/monaco-worker.ts
Normal file
8
frontend/app/utils/lean/monaco-worker.ts
Normal 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 }
|
||||
Reference in New Issue
Block a user