518 lines
20 KiB
Lua
518 lines
20 KiB
Lua
-- 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 = {}
|
||
|
||
-- Expansions the chain consumer decided on but did NOT perform, keyed
|
||
-- the same way. See `run_deferred` below for why they wait.
|
||
local deferred = {}
|
||
|
||
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
|
||
-- ---------------------------------------------------------------------
|
||
|
||
-- Right-gravity translation of `pos` through the effective edit —
|
||
-- pair.lua's shape, for the same reason: the point sits AFTER the
|
||
-- replaced span (on the terminator, or on a closer pairing inserted)
|
||
-- and has to move with it.
|
||
local function translate(pos, estart, estop, einserted)
|
||
if pos < estart then return pos end
|
||
if pos > estop then return pos - (estop - estart) + einserted end
|
||
return estart + einserted
|
||
end
|
||
|
||
-- Replace the pending span (leader + typed text) with `symbol`.
|
||
--
|
||
-- The span deliberately STOPS BEFORE the terminator. Including the
|
||
-- terminator would make the expansion and the terminator one edit, but
|
||
-- it would also swallow whatever auto-pairing did with that terminator
|
||
-- — and a pair character is a legal terminator (`\alp(`). One undo
|
||
-- restores the same text either way, because the terminator was its own
|
||
-- insert to begin with.
|
||
--
|
||
-- ONE `buf:replace`: 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, start, span_end, symbol)
|
||
local cursor_at = symbol:find(CURSOR, 1, true)
|
||
local text = cursor_at and (symbol:gsub("%$CURSOR", "", 1)) or symbol
|
||
|
||
-- The context to compare against AFTER the edit. A buffer intercept
|
||
-- may switch window or buffer while the replace runs; the point in
|
||
-- whatever it switched to is not ours to move.
|
||
local win0 = pmacs.window.current()
|
||
local point0 = ed.cursor()
|
||
|
||
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.
|
||
--
|
||
-- Context-guarded exactly as pair.lua's `repair_cursor` is: if the
|
||
-- intercept switched us elsewhere, `goto_byte` would move the point
|
||
-- of a buffer that has nothing to do with this expansion.
|
||
if pmacs.window.current() == win0 and pmacs.window.buffer() == buf then
|
||
if cursor_at then
|
||
ed.goto_byte(start + cursor_at - 1)
|
||
else
|
||
ed.goto_byte(translate(point0, estart, estop, einserted))
|
||
end
|
||
end
|
||
return start + #text
|
||
end
|
||
|
||
-- ---------------------------------------------------------------------
|
||
-- The consumer
|
||
-- ---------------------------------------------------------------------
|
||
|
||
-- Chain invocations not yet matched by a `run_deferred`.
|
||
--
|
||
-- `buffer.after-edit` fan-outs NEST: the typed-edit contract explicitly
|
||
-- supports a consumer calling `pmacs.hook.run("buffer.after-edit")`,
|
||
-- and a nested run re-enters every subscriber — including this module's
|
||
-- deferred-expansion subscriber, while the OUTER chain is still walking
|
||
-- its consumer list and pairing has not yet seen the terminator. A
|
||
-- nested run that performed the expansion would reproduce exactly the
|
||
-- bug deferring exists to fix: pairing resumes afterwards holding a
|
||
-- record the replace has invalidated, declines, and the closer is lost.
|
||
--
|
||
-- Counting has to happen INSIDE the chain and BEFORE any consumer that
|
||
-- might start a nested fan-out. A subscriber registered alongside
|
||
-- `run_deferred` is too late — the whole nested fan-out completes
|
||
-- inside the outer chain's subscriber, before either of them runs. And
|
||
-- counting in the expander itself is not enough: a lower-priority
|
||
-- consumer may CLAIM and stop the chain before the expander is
|
||
-- reached, so a nested pass would go uncounted while its
|
||
-- `run_deferred` still ran (round 11's fix, round 12's defect).
|
||
--
|
||
-- Hence a separate no-op consumer at the minimum priority, which runs
|
||
-- first in every chain invocation that reaches any consumer at all.
|
||
-- Its guarantee is exactly the ordering contract the chain already
|
||
-- rests on, and it degrades safely: the only thing that can skip it is
|
||
-- a claim ahead of it, which skips the expander too, so nothing is
|
||
-- queued in that fan-out either.
|
||
local depth = 0
|
||
|
||
local function count_fan_out()
|
||
depth = depth + 1
|
||
return false
|
||
end
|
||
|
||
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
|
||
-- ...and on a source edit whose context is no longer current. The
|
||
-- buffer and window matching is not enough: a redefined self-insert
|
||
-- can insert the character and THEN move the point, and expanding
|
||
-- over a span the user has left teleports them back into it. Pairing
|
||
-- makes the same three-part check for the same reason.
|
||
if ed.cursor() ~= rec.post_cursor 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
|
||
pending[fid] = nil
|
||
deferred[fid] = {
|
||
buffer = rec.buffer,
|
||
window = rec.window,
|
||
start_offset = p.start_offset,
|
||
text = extended,
|
||
symbol = best[extended].symbol,
|
||
re_arm = false,
|
||
}
|
||
end
|
||
-- Claimed either way: an extension that has not yet completed must
|
||
-- NOT reach auto-pairing (`\[` in `\[[]]`), and a completing one is
|
||
-- part of the abbreviation, not a character pairing should react to.
|
||
return true
|
||
end
|
||
|
||
-- `ch` does not extend the abbreviation: it TERMINATES it, and a
|
||
-- terminator is an ordinary character that auto-pairing is entitled
|
||
-- to react to (`\alp(` must give `α()`). So the expansion is
|
||
-- DEFERRED to the subscriber below and this returns false, leaving
|
||
-- pairing a record whose offsets still describe the buffer.
|
||
--
|
||
-- Expanding here and returning false would not do: the replace makes
|
||
-- pairing's copy of the record stale, so pairing declines and the
|
||
-- closer is silently lost. Expanding here and returning true is
|
||
-- worse — it is what shipped in the first revision of this file, and
|
||
-- it makes every pair-character terminator silently unpaired.
|
||
pending[fid] = nil
|
||
if best[p.text] and #p.text > 0 then
|
||
deferred[fid] = {
|
||
buffer = rec.buffer,
|
||
window = rec.window,
|
||
start_offset = p.start_offset,
|
||
text = p.text,
|
||
symbol = best[p.text].symbol,
|
||
-- A terminating `\` re-arms as a NEW leader at its own position
|
||
-- (`\al\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 nothing left open.
|
||
re_arm = ch == LEADER,
|
||
}
|
||
elseif ch == LEADER then
|
||
-- Nothing to expand, but the leader still opens a fresh
|
||
-- abbreviation where it landed.
|
||
pending[fid] = {
|
||
buffer = rec.buffer,
|
||
window = rec.window,
|
||
start_offset = rec.effective_start,
|
||
text = "",
|
||
expected_revision = revision,
|
||
}
|
||
return true
|
||
end
|
||
|
||
return false
|
||
end
|
||
|
||
-- The deferred expansion, on its own `buffer.after-edit` subscriber.
|
||
--
|
||
-- It runs AFTER the whole typed-edit chain — this chunk loads after
|
||
-- typed_edit.lua, and hook callbacks run in registration order — so
|
||
-- auto-pairing has already reacted to the terminator by the time the
|
||
-- expansion rewrites the text in front of it. Pairing's closer lands
|
||
-- after the terminator, outside the replaced span, so it survives.
|
||
--
|
||
-- It must also run BEFORE lsp.lua's subscriber (Q#AP7): that one
|
||
-- flushes `didChange` synchronously on the signature-trigger path, and
|
||
-- a server told about `\alp ` instead of `α ` stays wrong until the
|
||
-- next edit. This chunk loads before lsp.lua for exactly that reason.
|
||
--
|
||
-- A claim by ANY chain consumer stops the chain but not this — which
|
||
-- is the point. Pairing claims the terminator it reacts to.
|
||
local function run_deferred()
|
||
-- Match off this fan-out's chain invocation. `> 1` means the outer
|
||
-- chain is still mid-list — pairing has not had the terminator yet —
|
||
-- so the queued expansion stays queued for the outer pass. The clamp
|
||
-- keeps this honest if a claim beat the counting consumer, in which
|
||
-- case nothing was queued in that fan-out either.
|
||
local level = depth
|
||
if depth > 0 then depth = depth - 1 end
|
||
if level > 1 then return end
|
||
|
||
local fid = frontend_id()
|
||
if fid == nil then return end
|
||
local d = deferred[fid]
|
||
deferred[fid] = nil
|
||
if not d then return end
|
||
|
||
local buf = pmacs.window.buffer()
|
||
if not buf or buf ~= d.buffer or pmacs.window.current() ~= d.window then
|
||
return
|
||
end
|
||
|
||
-- The span must still hold exactly what was typed into it. Pairing
|
||
-- only edits at the point, which is past this span, so in practice
|
||
-- this holds; a buffer intercept is not obliged to be so polite.
|
||
local span_end = d.start_offset + 1 + #d.text
|
||
local ok, actual = pcall(function()
|
||
return buf:slice(d.start_offset, span_end)
|
||
end)
|
||
if not ok or actual ~= LEADER .. d.text then return end
|
||
|
||
local after = expand(buf, d.start_offset, span_end, d.symbol)
|
||
if after and d.re_arm then
|
||
local rev_ok, rev = pcall(function() return buf:revision() end)
|
||
if rev_ok then
|
||
pending[fid] = {
|
||
buffer = d.buffer,
|
||
window = d.window,
|
||
start_offset = after,
|
||
text = "",
|
||
expected_revision = rev,
|
||
}
|
||
end
|
||
end
|
||
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
|
||
deferred[fid] = nil
|
||
end)
|
||
|
||
pmacs.hook.add("buffer.after-edit", run_deferred)
|
||
|
||
-- `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)
|
||
|
||
-- Runs first in every chain invocation that reaches a consumer at all,
|
||
-- which is what makes the nesting count trustworthy — see `depth`. It
|
||
-- declines, always: it observes, it does not participate.
|
||
pmacs.typed_edit.add_consumer {
|
||
name = "lean-abbrev-fan-out-counter",
|
||
priority = -2147483648,
|
||
fn = count_fan_out,
|
||
}
|
||
|
||
pmacs.typed_edit.add_consumer {
|
||
name = "lean-abbrev",
|
||
priority = 50,
|
||
fn = on_typed_edit,
|
||
}
|