pmacs/builtin/runtime/indent.lua

131 lines
4.8 KiB
Lua

-- indent.lua --- auto-indent on newline (Arc 2).
--
-- RET (`edit.newline-and-indent`) inserts a newline plus the current
-- line's leading whitespace, verbatim, clipped at the split point
-- (Q#AI3): copying bytes is the only policy that cannot be wrong about
-- tabs-vs-spaces, and the clip keeps a split inside the indent from
-- double-indenting the carried text. The whole thing is ONE edit — one
-- undo step, one CRDT op. With a region it is one `buf:replace` (CUA
-- type-over, Q#AI4); the selection is cleared after every successful
-- edit, region or not (a zero-length selection would otherwise go live
-- the moment the cursor moves off the anchor). `buffer.newline` stays
-- bound-free as the plain-newline escape hatch (Q#AI2).
--
-- Framing: docs/auto-indent-framing.md.
pmacs.indent = pmacs.indent or {}
local ed = pmacs.editor
-- Start of the line containing `pos`: chunked backward scan for the
-- last newline strictly before it (comment.lua's scan — there is no
-- line-access API on buffers; giant lines stay safe).
local function line_start_before(buf, pos)
local p = pos
while p > 0 do
local from = math.max(0, p - 4096)
local chunk = buf:slice(from, p)
local nl = chunk:match("()\n[^\n]*$")
if nl then return from + nl end
p = from
end
return 0
end
-- The indent to carry over a split at `split` (Q#AI3):
-- bytes[line_start .. min(first_non_ws, split)]. Forward chunked scan
-- from the line start, stopping at the first non-whitespace byte —
-- never materializing more of the line than the indent itself plus
-- one chunk (Enter at the end of a giant minified line must not copy
-- the whole line just to produce an empty indent). `[ \t]` rather
-- than `%s` so a CR on a CRLF line never counts as indent.
local function indent_before(buf, split)
local start = line_start_before(buf, split)
local parts = {}
local p = start
while p < split do
local chunk_to = math.min(p + 4096, split)
local chunk = buf:slice(p, chunk_to)
local ws = chunk:match("^[ \t]*")
table.insert(parts, ws)
if #ws < #chunk then break end
p = chunk_to
end
return table.concat(parts)
end
-- Right-gravity translation of `pos` through the effective edit
-- (Q#AI5; the daemon optimistic-arm shape). `estop` is the PRE-edit
-- end of the replaced range; an insert has estart == estop.
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
-- edit.newline-and-indent body.
function pmacs.indent.newline()
local buf = pmacs.window.buffer()
if not buf then
ed.set_status("no buffer")
return false
end
-- Snapshot the context BEFORE the edit (Q#AI5): intercepts run with
-- the registry borrow released and may switch window or buffer; the
-- fix-up below must never touch whatever is active afterwards.
local win0 = pmacs.window.current()
local cursor0 = ed.cursor()
local region = ed.region()
local has_region = region ~= nil and region["end"] > region.start
local rstart, rstop
if has_region then
rstart, rstop = region.start, region["end"]
else
rstart, rstop = cursor0, cursor0
end
local text = "\n" .. indent_before(buf, rstart)
-- One edit = one undo step, one CRDT op. Same intercept discipline
-- as killring/comment: a rejection reports rather than throws and
-- leaves no state behind.
local ok, estart, estop, einserted = pcall(function()
if has_region then
return buf:replace(rstart, rstop, text)
end
return buf:insert(rstart, text)
end)
if not ok then
ed.set_status("newline-and-indent rejected by buffer intercept")
return false
end
-- Context guard (Q#AI5): fix up only the window that made the edit.
if pmacs.window.current() ~= win0 or pmacs.window.buffer() ~= buf then
ed.set_status("newline-and-indent: context changed during edit")
return false
end
-- A deviating effective edit means an intercept rewrote it — the
-- interceptor's positional result stands (M6.4: kind and payload
-- are immutable). Cursor repair uses ONE formula for the clean and
-- transformed paths alike: translate the pre-edit cursor through
-- the effective edit, then goto_byte (which clamps). The clean
-- insert-at-cursor case lands at estart + einserted — right after
-- the carried indent.
local deviated = estart ~= rstart or estop ~= rstop or einserted ~= #text
if deviated then
ed.set_status("newline-and-indent altered by buffer intercept")
end
ed.goto_byte(translate(cursor0, estart, estop, einserted))
ed.clear_selection()
return not deviated
end
pmacs.command.define {
name = "edit.newline-and-indent",
description = "Insert a newline carrying the current line's indentation.",
fn = function() pmacs.indent.newline() end,
}