pmacs/builtin/runtime/pair.lua

312 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.

-- pair.lua --- auto-pairing (Arc 2).
--
-- Typing `(` gives `()` with the cursor between; typing `)` when the
-- next char is already `)` steps over it instead of doubling it. The
-- carrier is a `buffer.after-edit` reaction (Q#AP1): the opener stays
-- a genuine single-codepoint self-insert — the classification
-- signature help depends on — and this hook inserts (or swallows) the
-- closer as a second edit. Provenance is the exact one-shot typed-edit
-- record (`pmacs.editor.take_typed_edit()`, Q#AP9), not buffer-text
-- inference: pastes, programmatic edits, manual hook runs, and a stale
-- `this_command` have no record and never pair, and a transformed,
-- relocated, or context-switching source self-insert fails closed.
--
-- This chunk loads BEFORE lsp.lua (Q#AP7): registration order is hook
-- execution order, and lsp.lua's after-edit callback synchronously
-- flushes didChange on the signature-trigger path — the closer must
-- already be in the buffer when that callback runs. Everything under
-- `pmacs.lsp` is therefore looked up lazily at callback time.
--
-- Framing: docs/auto-pairing-framing.md.
pmacs.pair = pmacs.pair or {}
local ed = pmacs.editor
-- Language → array of pair strings (opener codepoint followed by
-- closer codepoint), plus the `default` entry used when the language
-- is unknown or has no entry — pairing is useful in scratch buffers
-- (Q#AP2). Public and user-extensible, like `pmacs.comment.strings`:
-- pmacs.pair.sets.rust = { "()", "[]", "{}", '""', "''" }
-- Conservative defaults: no `'` (prose apostrophes, Rust lifetimes,
-- char literals), no backtick, outside the languages that want them.
-- NOTE (Q#AP1): only the nine built-in chars `()[]{}"'` and backtick
-- are excluded from the frontends' optimistic classifiers. A
-- user-added pair char beyond those still pairs, but arrives
-- optimistically: its opener is a source-peer op and the closer a
-- daemon-peer op, so its undo is cross-peer-degraded (documented
-- limitation; the general fix is chronological cross-peer undo
-- arbitration, named substrate work).
-- Per-buffer on/off switch (Q#CR8's flagship adopter). Read against the
-- SOURCE buffer of the typed edit, never the currently active one — see
-- the hook body below, which resolves it the same way `set_for` resolves
-- the buffer's pair set (round 2, finding 2): `rec.buffer`, not
-- `pmacs.window.buffer()`.
pmacs.config.define {
name = "editing.auto-pair",
description = "Automatically insert (and skip over) the closing half of a typed pair.",
type = "boolean",
default = true,
mutability = "live",
}
pmacs.pair.sets = {
default = { "()", "[]", "{}", '""' },
python = { "()", "[]", "{}", '""', "''" },
lua = { "()", "[]", "{}", '""', "''" },
javascript = { "()", "[]", "{}", '""', "''", "``" },
typescript = { "()", "[]", "{}", '""', "''", "``" },
javascriptreact = { "()", "[]", "{}", '""', "''", "``" },
typescriptreact = { "()", "[]", "{}", '""', "''", "``" },
markdown = { "()", "[]", "{}", '""', "``" },
sh = { "()", "[]", "{}", '""', "''" },
bash = { "()", "[]", "{}", '""', "''" },
}
-- Length of the well-formed UTF-8 sequence starting at `s[i]`, or nil
-- for anything ill-formed (Unicode 15, Table 3-7): continuation-byte
-- shapes are checked on EVERY trailing byte, and the narrowed
-- second-byte ranges exclude overlong encodings (C0/C1 leads,
-- E0 809F, F0 808F), UTF-16 surrogates (ED A0BF), and codepoints
-- beyond U+10FFFF (F5+ leads, F4 90+). Length-from-lead-byte alone
-- accepted "(\xC2x" as two "codepoints" (PR #110 round 2, finding 1).
local function utf8_seq_len(s, i)
local b1 = s:byte(i)
if not b1 then return nil end
if b1 < 0x80 then return 1 end
if b1 < 0xC2 or b1 > 0xF4 then return nil end
local b2 = s:byte(i + 1)
if not b2 or b2 < 0x80 or b2 > 0xBF then return nil end
if b1 < 0xE0 then return 2 end
if b1 == 0xE0 and b2 < 0xA0 then return nil end
if b1 == 0xED and b2 > 0x9F then return nil end
if b1 == 0xF0 and b2 < 0x90 then return nil end
if b1 == 0xF4 and b2 > 0x8F then return nil end
local b3 = s:byte(i + 2)
if not b3 or b3 < 0x80 or b3 > 0xBF then return nil end
if b1 < 0xF0 then return 3 end
local b4 = s:byte(i + 3)
if not b4 or b4 < 0x80 or b4 > 0xBF then return nil end
return 4
end
-- The first full UTF-8 codepoint starting at byte `pos`, as a string;
-- nil at end-of-buffer. Bytes that do not begin a well-formed
-- sequence (malformed file content, a truncated sequence at EOF)
-- yield the single raw byte instead: it matches neither whitespace
-- nor any validated closer, so the predicate conservatively treats
-- junk like a word character — never like EOL, which nil would mean.
local function char_at(buf, pos)
local len = buf:len()
if pos >= len then return nil end
local to = math.min(pos + 4, len)
local ok, s = pcall(function() return buf:slice(pos, to) end)
if not ok or type(s) ~= "string" or #s == 0 then return nil end
local n = utf8_seq_len(s, 1)
if not n or n > #s then return s:sub(1, 1) end
return s:sub(1, n)
end
-- Split a pair entry into (opener, closer): EXACTLY two well-formed
-- UTF-8 codepoints, no trailing bytes (PR #110 round 1 finding 3 +
-- round 2 finding 1 — "()x" and "(\xC2x" must be skipped entirely,
-- never partially honored). nil for malformed user additions:
-- skipped, not errors — the hook must never throw over a config typo.
local function split_pair(s)
if type(s) ~= "string" or #s < 2 then return nil end
local n1 = utf8_seq_len(s, 1)
if not n1 or n1 >= #s then return nil end
local n2 = utf8_seq_len(s, n1 + 1)
if not n2 or n1 + n2 ~= #s then return nil end
return s:sub(1, n1), s:sub(n1 + 1)
end
-- The pair set for `buf`: its language's entry if configured, else
-- `default`. Language resolves against the buffer the typed-edit
-- record names — NOT the currently active buffer, which a
-- context-switching command may have replaced by callback time
-- (PR #110 round 2, finding 2). `pmacs.lsp` is looked up lazily and
-- nil-guarded — this chunk loads before lsp.lua (Q#AP7). Non-table
-- values anywhere (a config typo like `pmacs.pair.sets.default =
-- "()"`) degrade to the default set, then to empty — never a throw
-- from the after-edit callback (round 2, finding 3).
local function set_for(buf)
local lang
if pmacs.lsp and pmacs.lsp.buffer_language then
local ok, l = pcall(pmacs.lsp.buffer_language, buf)
if ok then lang = l end
end
local sets = pmacs.pair.sets
if type(sets) ~= "table" then return {} end
local set = lang and sets[lang]
if type(set) ~= "table" then set = sets.default end
if type(set) ~= "table" then return {} end
return set
end
-- opener → closer, and the set of closer codepoints.
local function maps_for(set)
local openers, closers = {}, {}
for _, entry in ipairs(set) do
local o, c = split_pair(entry)
if o then
openers[o] = c
closers[c] = true
end
end
return openers, closers
end
-- Conservative insertion predicate (Q#AP3): pair only before
-- end-of-buffer, end-of-line, whitespace, or a closing char from the
-- active set — `foo|bar` + `(` gives `(bar`, never `()bar`.
local function should_pair(buf, cursor, closers)
local nxt = char_at(buf, cursor)
if nxt == nil then return true end
if nxt == "\n" or nxt == "\r" or nxt == " " or nxt == "\t" then return true end
return closers[nxt] == true
end
-- Right-gravity translation of `pos` through the effective edit —
-- indent.lua's repair shape (Q#AP3/Q#AP4 transformed outcomes).
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
-- Context-guarded cursor repair after a TRANSFORMED reaction edit:
-- the intercept's positional result stands (kind and payload are
-- immutable; the edit has already landed), so translate the pre-edit
-- cursor through the effective edit and clamp via goto_byte — unless
-- the intercept switched window or buffer, in which case the new
-- context is not ours to touch. The clean path deliberately performs
-- NO cursor motion: a clean at-cursor closer insert must leave the
-- cursor *before* the closer, which translation would not.
local function repair_cursor(win0, buf0, cursor0, estart, estop, einserted)
if pmacs.window.current() ~= win0 or pmacs.window.buffer() ~= buf0 then
return
end
ed.goto_byte(translate(cursor0, estart, estop, einserted))
end
-- Test facility (leading underscore = not stable API), OFF by
-- default: the one-shot record must stay ephemeral in production —
-- retaining every consumed record in a public field would defeat the
-- Q#AP9 contract the take API enforces (PR #110 round 1, finding 4).
-- Acceptance tests flip `_capture_records` on; each fan-out then
-- publishes the record it observed (or nil) to `_last_record`, which
-- is how tests read the exact codepoint / effective triple and prove
-- one-shot-ness (this callback registers first and consumes it).
pmacs.pair._capture_records = false
pmacs.hook.add("buffer.after-edit", function()
-- One-shot provenance (Q#AP9). Absence — paste, programmatic edit,
-- manual hook run, rejected insert, a post-insert mutation by the
-- command, stale `this_command` — is a silent non-event; only a
-- live record for a pair-set character that then fails a gate
-- reports.
local rec = ed.take_typed_edit and ed.take_typed_edit()
if pmacs.pair._capture_records then pmacs.pair._last_record = rec end
if not rec then return end
if not (ed.this_command and ed.this_command() == "buffer.self-insert") then return end
-- The master switch, per-buffer (Q#CR4): the SOURCE buffer of the
-- typed edit, resolved buffer-local -> global -> default(true). A
-- second buffer of the same language is untouched by a buffer-local
-- override here (acceptance 29).
if not pmacs.config.get("editing.auto-pair", rec.buffer) then return end
local buf = pmacs.window.buffer()
if not buf then return end
-- Relevance first (PR #110 round 1, finding 2): pairing has no
-- interest in characters outside the set, so a transformed or
-- relocated ordinary `a` must stay silent — the reports below are
-- for pair characters only. The set is the SOURCE buffer's (round
-- 2, finding 2): `'` typed in Rust stays silent even when a
-- context-switching command lands in Python, and `'` typed in
-- Python still draws the context-change report when it lands in
-- Rust.
local ch = rec.char
local openers, closers = maps_for(set_for(rec.buffer))
if not (openers[ch] or closers[ch]) then return end
-- Fail closed on a transformed source self-insert (Q#AP3): the
-- intercept's positional result stands as produced; pairing on top
-- of a relocated or expanded opener would compound it.
if not rec.clean then
ed.set_status("auto-pair skipped: source self-insert transformed")
return
end
-- Fail closed when the source edit's context is no longer current:
-- an intercept switched window/buffer, or something moved the
-- cursor off the post-insert position. Best-effort by construction:
-- the report needs this fan-out to run at all, and dispatch's
-- active-buffer revision compare (the named buffer-aware edit-epoch
-- deferral) skips the fan-out when a context-switching command
-- lands on a buffer with a coincidentally equal revision — pairing
-- still fails closed there, silently (the record dies un-armed).
if buf ~= rec.buffer
or pmacs.window.current() ~= rec.window
or ed.cursor() ~= rec.post_cursor then
ed.set_status("auto-pair skipped: source context changed")
return
end
-- Region guard (Q#AP3/Q#AP6): on the dispatch route type-over has
-- already consumed and cleared the region. A region surviving the
-- edit means the TUI's selection-blind optimistic gate let a custom
-- pair char through (named deferral) — reacting would pile a closer
-- onto an unconsumed region.
if ed.region() ~= nil then return end
local cursor = rec.post_cursor
-- Skip-over-close (Q#AP4), checked before insertion so symmetric
-- pairs (quotes) step over their own closer: typing `)` at `(|)`
-- swallows the freshly typed duplicate, net `()` with the cursor
-- after — exactly Emacs's skip. The pair chars round-trip (Q#AP1),
-- so no frontend ever painted the transient duplicate.
if closers[ch] then
local dup_ok, dup = pcall(function() return buf:slice(cursor, cursor + #ch) end)
if dup_ok and dup == ch then
local win0 = pmacs.window.current()
local ok, estart, estop, einserted = pcall(function()
return buf:delete(cursor, cursor + #ch)
end)
if not ok then
-- The duplicate stays (e.g. `())`); report, no retry.
ed.set_status("auto-pair skip rejected by buffer intercept")
return
end
if estart ~= cursor or estop ~= cursor + #ch or einserted ~= 0 then
ed.set_status("auto-pair skip altered by buffer intercept")
repair_cursor(win0, buf, cursor, estart, estop, einserted)
end
return
end
end
local closer = openers[ch]
if not closer then return end
if not should_pair(buf, cursor, closers) then return end
local win0 = pmacs.window.current()
local ok, estart, estop, einserted = pcall(function()
return buf:insert(cursor, closer)
end)
if not ok then
-- Nothing landed; the opener stands alone.
ed.set_status("auto-pair closer rejected by buffer intercept")
return
end
if estart ~= cursor or estop ~= cursor or einserted ~= #closer then
ed.set_status("auto-pair closer altered by buffer intercept")
repair_cursor(win0, buf, cursor, estart, estop, einserted)
end
-- Clean path: no cursor motion — the insert landed at the cursor
-- and Lua mutators move no cursors, so it already sits between the
-- pair; the daemon's per-tick CursorByte re-grounds both frontends.
end)