pmacs/builtin/runtime/lean_input.lua

363 lines
14 KiB
Lua
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

-- lean_input.lua --- the Lean 4 Unicode input method (Arc 8 Stage 4b).
--
-- Typing `\alpha` gives `α`; `\<>` gives `⟨⟩` with the point between.
-- The table is vendored in lean_abbrev.lua, generated from
-- vscode-lean4 — see that file's header and Q#LN11.
--
-- This is a typed-edit consumer (Stage 4a, Q#LN10) registered AHEAD of
-- auto-pairing at priority 50. The ordering is load-bearing, not
-- cosmetic: 64 abbreviation keys contain a character in the `lean4`
-- pair set (`\[[]]` → `⟦⟧`, `\{{}}` → `⦃⦄`), so with pairing first,
-- typing `\[` would insert `[]` with the point between and corrupt the
-- pending key to `\[]` before the second `[` arrives — `\[[]]` becomes
-- unreachable. Priority, not load order, is what decides this; that is
-- the whole reason Stage 4a exists.
--
-- The consumer therefore claims every keystroke that EXTENDS an open
-- pending abbreviation, not merely one that completes an expansion. A
-- consumer that claimed only completed expansions would hand each
-- intermediate `[` to pairing, which is the same corruption by a
-- different route. "Claimed" means the chain stops, not that an edit
-- was made (Q#LN22).
--
-- UNDO IS CROSS-PEER-DEGRADED, and this is accepted rather than papered
-- over (Q#LN21). `classify_key` (src/optimistic.rs) returns `Insert(c)`
-- for `\` and for every ASCII letter — only the nine built-in pair
-- chars are excluded — so on a CRDT frontend `\alpha` arrives as six
-- SOURCE-peer optimistic inserts while the expansion is a single
-- DAEMON-peer replace spanning all six. Undo across that boundary is
-- not chronologically arbitrated. This is the same defect Q#LN6 already
-- accepts for `⟨⟩`, one order of magnitude wider: it is every
-- abbreviation the user types, not a few brackets. The general fix is
-- chronological cross-peer undo arbitration, named substrate work.
-- `set_round_trip_input` would fix it and is rejected — it also makes
-- `dispatch_idle` report false, so RET would stop inserting a newline.
--
-- Framing: docs/lean4-mode-framing.md Q#LN11, Q#LN21, Q#LN22.
pmacs.lean_input = pmacs.lean_input or {}
local ed = pmacs.editor
local LEADER = "\\"
local CURSOR = "$CURSOR"
pmacs.config.define {
name = "lean.abbrev",
description = "Expand \\-prefixed abbreviations into Unicode symbols in Lean 4 buffers.",
type = "boolean",
default = true,
mutability = "live",
}
-- ---------------------------------------------------------------------
-- The table, and the two indexes derived from it at load time
-- ---------------------------------------------------------------------
-- `best[p]` is the symbol for the shortest key having `p` as a prefix,
-- ties broken by the key's position in the vendored sequence. Both
-- halves matter: 101 prefixes have equal-shortest candidates that
-- resolve to DIFFERENT symbols (`f` → `` from `f<`, not `` from
-- `f>`), and the sequence's order is the only place that tie is
-- recorded. `pairs` over a map-shaped table could not express it.
--
-- `eager[k]` marks the 1,550 keys that are complete and have no longer
-- key extending them — the ones that expand the moment they are typed,
-- with no terminator. `to` is NOT one of them (`top`, `to0`, `toa`),
-- which is exactly the case that reads as eager until the table is
-- consulted.
local best, eager = {}, {}
do
local seq = pmacs.lean_abbrev
if type(seq) ~= "table" then seq = {} end
local extended = {}
for i = 1, #seq do
local entry = seq[i]
local key, symbol = entry[1], entry[2]
-- Walk every prefix of the key, including the key itself. Iterating
-- the sequence in order and only overwriting on a STRICTLY shorter
-- key is what makes the source-order tiebreak fall out: an equal
-- length arriving later loses to the one already recorded.
for n = 1, #key do
local p = key:sub(1, n)
local cur = best[p]
if cur == nil or #key < cur.len then
best[p] = { symbol = symbol, len = #key }
end
if n < #key then extended[p] = true end
end
end
for i = 1, #seq do
local key = seq[i][1]
if not extended[key] then eager[key] = true end
end
end
-- Test seam (leading underscore = not stable API). Acceptance 45g reads
-- these to pin self-consistency properties a corrupt emit would break —
-- it cannot diff against `abbreviations.json`, which is not shipped.
function pmacs.lean_input._resolve(text)
local hit = best[text]
return hit and hit.symbol or nil
end
function pmacs.lean_input._is_eager(key)
return eager[key] == true
end
-- ---------------------------------------------------------------------
-- Pending state: one record per FRONTEND (Q#LN22)
-- ---------------------------------------------------------------------
-- Keyed by frontend id, with the buffer stored inside and compared by
-- value. Q#LN22 specifies the key as `(frontend, buffer)`; a per-
-- frontend slot is equivalent here and avoids inventing a scalar
-- buffer key (`BufferId`'s inner value is deliberately private, R22).
-- The generality a two-level map would add is unreachable: a frontend
-- has one point, and `buffer.after-switch` clears that frontend's slot,
-- so no frontend can hold pending state in a buffer it is not in.
--
-- Per-frontend rather than per-buffer is NOT a refinement — a buffer-
-- keyed table lets either frontend consume or discard the other's
-- half-typed abbreviation in a shared buffer, which is the ordinary
-- TUI-plus-GPU configuration this project ships.
local pending = {}
local function frontend_id()
local ok, id = pcall(function() return pmacs.frontend.id() end)
if ok then return id end
return nil
end
-- Is `rec` a typed edit that continues `p` exactly? Conservative by
-- construction (Q#LN22): abandonment is LAZY because pmacs has no
-- cursor-motion hook, so every guard that would have been checked at
-- the moment the user left is checked here instead, at the next typed
-- edit.
local function still_valid(p, rec, buf)
if p.buffer ~= rec.buffer or p.window ~= rec.window then return false end
-- The point must still be at the end of the pending span: the leader,
-- plus what has been typed into it, plus the character that just
-- landed.
if rec.effective_start ~= p.start_offset + 1 + #p.text then return false end
-- Exactly one edit since this frontend last extended the pending
-- abbreviation — the one being processed now. Deliberately strict
-- across frontends: `revision()` is BUFFER-GLOBAL, so a peer editing
-- the shared buffer invalidates this record even though it edited
-- elsewhere. Keeping it alive would mean translating and validating
-- the span through arbitrary peer edits, substrate Stage 4b does not
-- add.
local ok, rev = pcall(function() return buf:revision() end)
if not ok or rev ~= p.expected_revision + 1 then return false end
return true
end
-- ---------------------------------------------------------------------
-- Expansion
-- ---------------------------------------------------------------------
-- Replace the pending span with `symbol`, placing the point at
-- `$CURSOR` if the symbol carries one. Returns the byte offset just
-- past the replacement, or nil when the edit was rejected or altered.
--
-- ONE `buf:replace` for the whole expansion: one undo step, one CRDT
-- op, one effective-edit verification. A rejection drops the pending
-- state and does not retry, the same discipline as comment.lua's Q#CT5
-- and pair.lua.
local function expand(buf, p, symbol, span_end)
local cursor_at = symbol:find(CURSOR, 1, true)
local text = cursor_at and (symbol:gsub("%$CURSOR", "", 1)) or symbol
local start = p.start_offset
local ok, estart, estop, einserted = pcall(function()
return buf:replace(start, span_end, text)
end)
if not ok then
ed.set_status("lean abbreviation rejected by buffer intercept")
return nil
end
if estart ~= start or estop ~= span_end or einserted ~= #text then
ed.set_status("lean abbreviation altered by buffer intercept")
return nil
end
-- The point MUST be placed explicitly. Unlike pairing's at-cursor
-- insert, this replace SHRINKS the buffer — `\alpha` (6 bytes)
-- becomes `α` (2) — and a point left at the pre-edit offset is past
-- the new end. Every later self-insert is then silently rejected and
-- the editor looks dead. There is no daemon re-grounding that covers
-- this; that only holds for an edit that lands at the cursor.
ed.goto_byte(cursor_at and (start + cursor_at - 1) or (start + #text))
return start + #text
end
-- ---------------------------------------------------------------------
-- The consumer
-- ---------------------------------------------------------------------
local function on_typed_edit(rec)
local fid = frontend_id()
if fid == nil then return false end
-- A fan-out carrying no record is still information: a paste,
-- programmatic edit or replicated op landed, so whatever this
-- frontend had pending no longer describes the buffer. Drop it and
-- decline — this is why the chain calls consumers with nil rather
-- than skipping them (Q#LN10).
if not rec then
pending[fid] = nil
return false
end
if not (ed.this_command and ed.this_command() == "buffer.self-insert") then
pending[fid] = nil
return false
end
-- Both gates resolve against the SOURCE buffer of the typed edit, not
-- the active one — a context-switching command may have replaced it
-- by callback time (pair.lua round 2, finding 2).
if not pmacs.config.get("lean.abbrev", rec.buffer) then
pending[fid] = nil
return false
end
local lang
if pmacs.lsp and pmacs.lsp.buffer_language then
local ok, l = pcall(pmacs.lsp.buffer_language, rec.buffer)
if ok then lang = l end
end
if lang ~= "lean4" then
-- No pending abbreviation is ever OPENED outside a `lean4` buffer:
-- `\` in Rust is an ordinary character and `\[` there still pairs.
pending[fid] = nil
return false
end
local buf = pmacs.window.buffer()
if not buf or buf ~= rec.buffer or pmacs.window.current() ~= rec.window then
pending[fid] = nil
return false
end
-- Fail closed on a transformed source self-insert, as pairing does:
-- expanding on top of a relocated or rewritten character compounds
-- the intercept's result.
if not rec.clean then
pending[fid] = nil
return false
end
local revision
do
local ok, rev = pcall(function() return buf:revision() end)
if not ok then
pending[fid] = nil
return false
end
revision = rev
end
local p = pending[fid]
if p and not still_valid(p, rec, buf) then
p = nil
pending[fid] = nil
end
local ch = rec.char
-- No pending abbreviation: only the leader opens one.
if not p then
if ch == LEADER then
pending[fid] = {
buffer = rec.buffer,
window = rec.window,
start_offset = rec.effective_start,
text = "",
expected_revision = revision,
}
-- Claimed: the leader belongs to the abbreviation, and pairing
-- has no interest in it either way.
return true
end
return false
end
-- Pending: does any key still have `text .. ch` as a prefix?
local extended = p.text .. ch
if best[extended] then
p.text = extended
p.expected_revision = revision
if eager[extended] then
local span_end = p.start_offset + 1 + #extended
pending[fid] = nil
expand(buf, p, best[extended].symbol, span_end)
end
-- Claimed either way: an extension that has not yet completed must
-- NOT reach auto-pairing (`\[` in `\[[]]`).
return true
end
-- `ch` does not extend the abbreviation. Expand what is pending
-- FIRST, then let `ch` stand as ordinary text — the terminator is
-- retained, not consumed, and it sits inside the replaced span so the
-- whole thing is one undo step.
pending[fid] = nil
local hit = best[p.text]
local after
if hit and #p.text > 0 then
-- `span_end` covers the terminator: the leader, the pending text,
-- and `ch`, which has already landed. What replaces it is the
-- symbol followed by `ch` itself.
local span_end = p.start_offset + 1 + #p.text + #ch
after = expand(buf, p, hit.symbol .. ch, span_end)
end
-- A terminating `\` re-arms as a NEW leader at its own position
-- (`\alpha\to` → `α→`). Upstream gets this from `processChange`,
-- where a finished abbreviation reports `isAffected = false` and so
-- does not suppress the new-leader branch. This is not the `\\` case:
-- there the pending text is empty, `\` EXTENDS, and the result is one
-- literal backslash with no pending state left open.
if ch == LEADER then
local start = after and (after - #ch) or rec.effective_start
local ok, rev = pcall(function() return buf:revision() end)
if ok then
pending[fid] = {
buffer = rec.buffer,
window = rec.window,
start_offset = start,
text = "",
expected_revision = rev,
}
end
return true
end
-- Claimed only if an expansion actually happened. Otherwise `ch` is
-- an ordinary character in a Lean buffer and auto-pairing should see
-- it — `\zz` leaves `z` free to pair if it ever were a pair char.
return after ~= nil
end
-- Q#KR11's seam: a detached frontend's pending state must not outlive
-- it. Ids are monotonic, so this table would otherwise grow for the
-- life of the session.
pmacs.hook.add("frontend.detached", function(fid)
pending[fid] = nil
end)
-- `buffer.after-switch` fires with NO arguments, so it cannot say whose
-- switch it was. The acting frontend is the one that produced the most
-- recent dispatched input event, which is what `pmacs.frontend.id()`
-- reports at callback time. Clearing every entry instead would let one
-- frontend's navigation discard another's half-typed abbreviation.
pmacs.hook.add("buffer.after-switch", function()
local fid = frontend_id()
if fid ~= nil then pending[fid] = nil end
end)
pmacs.typed_edit.add_consumer {
name = "lean-abbrev",
priority = 50,
fn = on_typed_edit,
}