284 lines
9.5 KiB
Lua
284 lines
9.5 KiB
Lua
-- pmacs-magit/fold.lua --- Foldable-section primitive (T M8.5).
|
|
--
|
|
-- This module is the load-bearing piece of pmacs-magit: parsing a
|
|
-- caller-provided section spec into a tree, rendering it as plain
|
|
-- text, and projecting it through a fold-state map. M8.6 and M8.7
|
|
-- (Git status integration, gestures) build on this primitive.
|
|
--
|
|
-- The module is intentionally self-contained --- it has no
|
|
-- dependency on `pmacs.*` Lua APIs and no side effects. It works
|
|
-- on plain Lua tables and strings. That makes it
|
|
--
|
|
-- * unit-testable from the same Lua VM the test fixture uses,
|
|
-- * promotable to a shared package layer if M8.6 finds the
|
|
-- pattern wants it (per the M11 substance-floor logic --- the
|
|
-- audit decides; speculation here is just a clean module
|
|
-- boundary, not an API contract).
|
|
--
|
|
-- Public surface (all under the returned table):
|
|
--
|
|
-- parse(spec) -> flat array of sections (DFS preorder)
|
|
-- render_source(flat) -> { text = ..., line_index = ... }
|
|
-- render_visible(flat, fs) -> { text = ..., line_index = ...,
|
|
-- fold_targets = ... }
|
|
-- section_at(line_idx, projection)
|
|
-- -> section id at that visible line
|
|
-- toggle(fold_state, id) -> mutates fold_state in place
|
|
--
|
|
-- "spec" is a tree:
|
|
--
|
|
-- { id = "a", title = "Section A", body = "line1\nline2",
|
|
-- children = {
|
|
-- { id = "a1", title = "Subsection A1", body = "..." },
|
|
-- },
|
|
-- }
|
|
--
|
|
-- Either the top-level spec is a single root or an array of roots.
|
|
-- IDs must be unique across the tree (validated at parse time).
|
|
-- Body is optional; nil-or-empty-string means a header-only
|
|
-- section.
|
|
|
|
local M = {}
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- Parse
|
|
-- ---------------------------------------------------------------------------
|
|
|
|
local function is_array(t)
|
|
return type(t) == "table" and (t[1] ~= nil or next(t) == nil)
|
|
end
|
|
|
|
local function copy_body_lines(body)
|
|
if body == nil or body == "" then return {} end
|
|
if type(body) ~= "string" then
|
|
error("section body must be a string; got " .. type(body))
|
|
end
|
|
local lines = {}
|
|
-- Match Lua's standard newline-split: empty strings between
|
|
-- consecutive newlines are preserved; a trailing newline does
|
|
-- *not* generate a phantom empty line.
|
|
local i = 1
|
|
while i <= #body + 1 do
|
|
local nl = body:find("\n", i, true)
|
|
if nl then
|
|
lines[#lines + 1] = body:sub(i, nl - 1)
|
|
i = nl + 1
|
|
else
|
|
lines[#lines + 1] = body:sub(i)
|
|
break
|
|
end
|
|
end
|
|
-- A body that ends with \n produced one trailing empty entry; drop it.
|
|
if body:sub(-1) == "\n" and lines[#lines] == "" then
|
|
lines[#lines] = nil
|
|
end
|
|
return lines
|
|
end
|
|
|
|
local function visit(node, depth, parent_id, out, seen_ids)
|
|
if type(node) ~= "table" then
|
|
error("section spec entries must be tables; got " .. type(node))
|
|
end
|
|
if type(node.id) ~= "string" or node.id == "" then
|
|
error("section spec missing string id (got " .. tostring(node.id) .. ")")
|
|
end
|
|
if seen_ids[node.id] then
|
|
error("duplicate section id '" .. node.id .. "'")
|
|
end
|
|
seen_ids[node.id] = true
|
|
if type(node.title) ~= "string" then
|
|
error("section '" .. node.id .. "' missing string title")
|
|
end
|
|
out[#out + 1] = {
|
|
id = node.id,
|
|
parent_id = parent_id,
|
|
depth = depth,
|
|
title = node.title,
|
|
body_lines = copy_body_lines(node.body),
|
|
-- We compute child_ids in a second pass so the parent knows
|
|
-- what to skip when collapsed.
|
|
child_ids = {},
|
|
}
|
|
local self_idx = #out
|
|
if node.children ~= nil then
|
|
if not is_array(node.children) then
|
|
error("section '" .. node.id .. "' children must be an array")
|
|
end
|
|
for _, c in ipairs(node.children) do
|
|
visit(c, depth + 1, node.id, out, seen_ids)
|
|
out[self_idx].child_ids[#out[self_idx].child_ids + 1] = c.id
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Parse a user spec into a flat array of sections in DFS preorder.
|
|
-- The array's order *is* the source-buffer order: section i appears
|
|
-- before section j when i precedes j in this list. Each entry:
|
|
--
|
|
-- { id, parent_id, depth, title, body_lines, child_ids }
|
|
--
|
|
-- depth starts at 0 for top-level sections.
|
|
function M.parse(spec)
|
|
if type(spec) ~= "table" then
|
|
error("section spec must be a table; got " .. type(spec))
|
|
end
|
|
local out = {}
|
|
local seen_ids = {}
|
|
-- Accept either a single root or an array of roots. An empty
|
|
-- array is allowed at the parse layer (returns no sections);
|
|
-- callers that require at least one section enforce that on
|
|
-- their side so the error names the calling API rather than
|
|
-- the parser.
|
|
if is_array(spec) and spec[1] ~= nil then
|
|
for _, node in ipairs(spec) do
|
|
visit(node, 0, nil, out, seen_ids)
|
|
end
|
|
elseif is_array(spec) then
|
|
-- empty array: zero roots; out stays empty.
|
|
else
|
|
visit(spec, 0, nil, out, seen_ids)
|
|
end
|
|
return out
|
|
end
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- Render
|
|
-- ---------------------------------------------------------------------------
|
|
|
|
-- Produce the indented header text for a section. The depth-1
|
|
-- indent is two spaces; the header carries no fold marker because
|
|
-- the source must be fold-invariant. Visible projection adds the
|
|
-- marker.
|
|
local function header_line_source(section)
|
|
return string.rep(" ", section.depth) .. section.title
|
|
end
|
|
|
|
-- Visible header includes a fold marker: "v " for expanded
|
|
-- (foldable), "> " for collapsed (foldable), " " for non-foldable
|
|
-- (no body, no children --- the marker would be misleading because
|
|
-- there's nothing to fold). Foldable = has body OR has children;
|
|
-- a body-only leaf is foldable because its body genuinely hides.
|
|
local function header_line_visible(section, foldable, expanded)
|
|
local marker
|
|
if not foldable then
|
|
marker = " "
|
|
elseif expanded then
|
|
marker = "v "
|
|
else
|
|
marker = "> "
|
|
end
|
|
return string.rep(" ", section.depth) .. marker .. section.title
|
|
end
|
|
|
|
local function is_foldable(section)
|
|
return #section.child_ids > 0 or #section.body_lines > 0
|
|
end
|
|
|
|
local function body_line(section, raw)
|
|
-- Body indented one further level than the header.
|
|
return string.rep(" ", section.depth + 1) .. raw
|
|
end
|
|
|
|
-- Render the source text: every section's header + body, every
|
|
-- child, depth-first, regardless of fold state. Returns
|
|
-- { text = "...", line_index = { [line_no] = section_id } }
|
|
-- where line_no is 0-indexed and points to either a header or a
|
|
-- body line; in the latter case the section_id is the section
|
|
-- whose body it belongs to.
|
|
function M.render_source(flat)
|
|
local lines = {}
|
|
local line_index = {}
|
|
for _, s in ipairs(flat) do
|
|
lines[#lines + 1] = header_line_source(s)
|
|
line_index[#lines - 1] = s.id
|
|
for _, b in ipairs(s.body_lines) do
|
|
lines[#lines + 1] = body_line(s, b)
|
|
line_index[#lines - 1] = s.id
|
|
end
|
|
end
|
|
return { text = table.concat(lines, "\n"), line_index = line_index }
|
|
end
|
|
|
|
-- Render the visible projection. fold_state is a table mapping
|
|
-- section_id to one of: nil / "expanded" (visible) / "collapsed"
|
|
-- (header shown, body + descendants hidden). Returns
|
|
-- {
|
|
-- text = "...",
|
|
-- line_index = { [line_no] = section_id },
|
|
-- fold_targets = { [line_no] = section_id }, -- only header lines
|
|
-- }
|
|
-- The fold_targets table is the lookup the toggle-fold command
|
|
-- uses: a cursor on a header line maps to the section it heads;
|
|
-- a cursor on a body line maps to the section whose body it is
|
|
-- (toggle on body folds the parent header too --- magit
|
|
-- semantics).
|
|
function M.render_visible(flat, fold_state)
|
|
fold_state = fold_state or {}
|
|
local lines = {}
|
|
local line_index = {}
|
|
local fold_targets = {}
|
|
|
|
-- Pre-build a "skip until depth <= K" pointer using DFS-preorder
|
|
-- properties: children of a collapsed section come immediately
|
|
-- after it in `flat`, until we hit a section at the collapsed
|
|
-- section's own depth (or shallower).
|
|
local i = 1
|
|
local n = #flat
|
|
while i <= n do
|
|
local s = flat[i]
|
|
local foldable = is_foldable(s)
|
|
local expanded = (fold_state[s.id] ~= "collapsed")
|
|
|
|
-- Header line for s.
|
|
lines[#lines + 1] = header_line_visible(s, foldable, expanded)
|
|
line_index[#lines - 1] = s.id
|
|
fold_targets[#lines - 1] = s.id
|
|
|
|
if expanded then
|
|
-- Body lines belong to s; cursor on a body line should
|
|
-- toggle s's fold (consistent with magit's treatment of
|
|
-- "section under point").
|
|
for _, b in ipairs(s.body_lines) do
|
|
lines[#lines + 1] = body_line(s, b)
|
|
line_index[#lines - 1] = s.id
|
|
fold_targets[#lines - 1] = s.id
|
|
end
|
|
i = i + 1 -- continue with children, if any
|
|
else
|
|
-- Skip body and descendants: jump i past every entry whose
|
|
-- depth is greater than s.depth.
|
|
i = i + 1
|
|
while i <= n and flat[i].depth > s.depth do
|
|
i = i + 1
|
|
end
|
|
end
|
|
end
|
|
|
|
return {
|
|
text = table.concat(lines, "\n"),
|
|
line_index = line_index,
|
|
fold_targets = fold_targets,
|
|
}
|
|
end
|
|
|
|
-- Look up the section id under a given visible line. Returns nil
|
|
-- if the line is out of range (cursor past end of buffer).
|
|
function M.section_at(visible_projection, line_no)
|
|
return visible_projection.fold_targets[line_no]
|
|
end
|
|
|
|
-- Toggle fold state for a section id. nil/expanded -> collapsed;
|
|
-- collapsed -> expanded. Mutates the table in place. Body-only
|
|
-- leaves are foldable because collapsing them hides their body;
|
|
-- true leaves (no body, no children) can carry a tracked state, but
|
|
-- rendering them is unchanged because there is nothing to hide.
|
|
function M.toggle(fold_state, id)
|
|
if fold_state[id] == "collapsed" then
|
|
fold_state[id] = "expanded"
|
|
else
|
|
fold_state[id] = "collapsed"
|
|
end
|
|
end
|
|
|
|
return M
|