pmacs/builtin/runtime/comment.lua

209 lines
6.4 KiB
Lua

-- comment.lua --- language-aware comment/uncomment (Arc 2).
--
-- `M-;` (`edit.toggle-comment`) comments or uncomments the current
-- line — or every line the region touches — using the language's line
-- prefix from the public `pmacs.comment.strings` table. Semantics
-- (Q#CT4): uncomment iff every non-blank line already starts (after
-- its indentation) with the prefix; otherwise comment, inserting
-- `prefix .. " "` at the minimum indentation of the span's non-blank
-- lines (Emacs comment-region alignment). Blank lines are skipped in
-- both directions. The whole toggle is ONE `buf:replace` (Q#CT5): one
-- undo step, one CRDT op, one effective-edit verification.
--
-- Named deviation (Q#CT2): the no-region case is Emacs `comment-line`
-- (toggle, then move to the next line so repeated `M-;` walks a
-- block), not `comment-dwim`'s append-comment-at-EOL.
--
-- Framing: docs/comment-toggle-framing.md.
pmacs.comment = pmacs.comment or {}
local ed = pmacs.editor
-- Language → line-comment prefix (Q#CT3). Public and user-extensible,
-- like `pmacs.lsp.filetypes`: `pmacs.comment.strings.mylang = ";;"`.
-- Block comments are a named deferral.
pmacs.comment.strings = {
rust = "//",
c = "//",
cpp = "//",
go = "//",
zig = "//",
javascript = "//",
typescript = "//",
javascriptreact = "//",
typescriptreact = "//",
lua = "--",
python = "#",
bash = "#",
sh = "#",
toml = "#",
yaml = "#",
}
-- Start of the line containing `pos`: chunked backward scan for the
-- last newline strictly before it (same chunk discipline as
-- killring's forward scan — 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
-- Byte offset of the first newline at or after `pos`, or `len`.
local function line_end_at(buf, pos, len)
local p = pos
while p < len do
local chunk_to = math.min(p + 4096, len)
local chunk = buf:slice(p, chunk_to)
local nl = chunk:find("\n", 1, true)
if nl then return p + nl - 1 end
p = chunk_to
end
return len
end
-- Split span text (no trailing newline) into lines, preserving empties.
local function split_lines(text)
local lines = {}
local i = 1
while true do
local nl = text:find("\n", i, true)
if not nl then
table.insert(lines, text:sub(i))
break
end
table.insert(lines, text:sub(i, nl - 1))
i = nl + 1
end
return lines
end
local function is_blank(line)
return line:match("^%s*$") ~= nil
end
-- Leading indentation in BYTES. `[ \t]` rather than `%s` so a CR on a
-- CRLF line never counts as indent.
local function indent_of(line)
return line:match("^[ \t]*")
end
-- edit.toggle-comment body.
function pmacs.comment.toggle()
local buf = pmacs.window.buffer()
if not buf then
ed.set_status("no buffer")
return false
end
local lang = pmacs.lsp.active_buffer_language()
local prefix = lang and pmacs.comment.strings[lang]
if not prefix then
ed.set_status("no comment syntax known for " .. (lang or "this buffer"))
return false
end
local len = buf:len()
local region = ed.region()
local has_region = region ~= nil and region["end"] > region.start
local span_first, span_last_end
if has_region then
span_first = line_start_before(buf, region.start)
-- The last line the region TOUCHES: a region ending at column 0
-- stops at the previous line (Emacs comment-region), hence end-1.
span_last_end = line_end_at(buf, region["end"] - 1, len)
else
local cursor = ed.cursor()
span_first = line_start_before(buf, cursor)
span_last_end = line_end_at(buf, cursor, len)
end
local lines = split_lines(buf:slice(span_first, span_last_end))
-- Classify (Q#CT4): uncomment iff every non-blank line is commented;
-- blank lines neither count nor contribute to the min indent.
local any_nonblank = false
local all_commented = true
local min_indent = nil
for _, line in ipairs(lines) do
if not is_blank(line) then
any_nonblank = true
local ind = indent_of(line)
if line:sub(#ind + 1, #ind + #prefix) ~= prefix then
all_commented = false
end
if min_indent == nil or #ind < min_indent then min_indent = #ind end
end
end
if not any_nonblank then
ed.set_status("nothing to comment")
return false
end
for i, line in ipairs(lines) do
if not is_blank(line) then
if all_commented then
local ind = indent_of(line)
local rest = line:sub(#ind + 1 + #prefix)
if rest:sub(1, 1) == " " then rest = rest:sub(2) end
lines[i] = ind .. rest
else
lines[i] = line:sub(1, min_indent)
.. prefix
.. " "
.. line:sub(min_indent + 1)
end
end
end
local new_text = table.concat(lines, "\n")
-- One replace = one undo step, one CRDT op (Q#CT5). Same intercept
-- discipline as killring: a rejection reports rather than throws,
-- and any deviation of the EFFECTIVE edit from the request means an
-- intercept rewrote it — the interceptor's result stands and the
-- cursor fix-up is skipped (a moved span makes it meaningless).
local ok, estart, estop, einserted = pcall(function()
return buf:replace(span_first, span_last_end, new_text)
end)
if not ok then
ed.set_status("comment toggle rejected by buffer intercept")
return false
end
if estart ~= span_first or estop ~= span_last_end or einserted ~= #new_text then
ed.set_status("comment toggle altered by buffer intercept")
return false
end
if has_region then
-- CUA convention after a region op: selection off, cursor at the
-- span start (Q#CT2).
ed.clear_selection()
ed.goto_byte(span_first)
else
-- comment-line behavior: move to the next line so repeated M-;
-- walks down the block. The byte after the rewritten span is the
-- old trailing newline iff one existed.
local new_end = span_first + #new_text
if new_end < buf:len() then
ed.goto_byte(new_end + 1)
else
ed.goto_byte(new_end)
end
end
return true
end
pmacs.command.define {
name = "edit.toggle-comment",
description = "Comment or uncomment the current line or selected lines.",
fn = function() pmacs.comment.toggle() end,
}
pmacs.keymap.bind { scope = "global", sequence = "M-;", command = "edit.toggle-comment" }