209 lines
6.4 KiB
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" }
|