445 lines
15 KiB
Lua
445 lines
15 KiB
Lua
-- pmacs-outline/init.lua --- Outline structure parser & view (T M8.9).
|
|
--
|
|
-- The third of M8's three universality-proof packages, after
|
|
-- dired-class (M8.2-M8.4) and magit-class (M8.5-M8.8). Validates
|
|
-- the *parsed-structure-from-rope* shape: a source buffer holds the
|
|
-- raw outline text; a paired visible buffer is a read-only
|
|
-- projection through fold state; selective rendering and folding
|
|
-- both derive from the parsed structure.
|
|
--
|
|
-- Architecture (T M8.9 --- two-buffer projection, M8.5 magit-class
|
|
-- precedent):
|
|
--
|
|
-- * source buffer: caller-owned, editable. Holds the canonical
|
|
-- outline text. The parser observes edits via an intercept
|
|
-- and maintains a lazy-incremental cache (parser.lua).
|
|
--
|
|
-- * visible buffer: created by `M.open`, read-only. Its content
|
|
-- is a byte-substituted projection of source: each top-level
|
|
-- folded subtree's body bytes are replaced with a ` ...\n`
|
|
-- marker. Headlines stay in source order; navigation in the
|
|
-- visible buffer maps back to source byte offsets via the
|
|
-- projection's line index.
|
|
--
|
|
-- * fold_state: per-handle table keyed by an entry's source
|
|
-- byte_start. fold-subtree on a headline toggles its entry.
|
|
-- Fold state survives source edits because the dirty intercept
|
|
-- shifts every fold key by the same delta the parser uses on
|
|
-- the corresponding entry's byte_start. Folds whose headline
|
|
-- byte itself was deleted (delete or replace ranges that
|
|
-- include the entry's `*` byte) are dropped, matching what the
|
|
-- parser does to the entry itself. If a fold's keyed byte ends
|
|
-- up not corresponding to any entry (e.g., user manually broke
|
|
-- the headline by inserting non-newline text), the fold is
|
|
-- silently ignored on next repaint --- view.render only honors
|
|
-- keys that match a current entry's byte_start.
|
|
--
|
|
-- * repaint cadence: lazy. A source edit marks the projection
|
|
-- dirty in the source intercept; the next visible-buffer
|
|
-- operation (fold toggle, navigation command) repaints first,
|
|
-- then runs. This avoids paying render cost on every keystroke
|
|
-- when no view operation follows.
|
|
--
|
|
-- * intercept on visible: rejects every user edit. Package paints
|
|
-- bypass via the painting flag with a pcall guard, the
|
|
-- established CC-1 pattern (this is outline-class added to
|
|
-- CC-1's observed-in list at M8.10/M8.11).
|
|
--
|
|
-- Public surface:
|
|
--
|
|
-- local outline = require("pmacs-outline")
|
|
-- local handle = outline.open(source_buf) -- creates visible projection
|
|
-- paired with source_buf;
|
|
-- returns handle with
|
|
-- .source / .visible /
|
|
-- .parser_handle
|
|
-- outline.close(handle) -- removes intercepts,
|
|
-- drops the visible buffer
|
|
-- outline.query(source_buf, predicate) -- public structure query;
|
|
-- also installed as
|
|
-- pmacs.outline.query
|
|
--
|
|
-- M-x pmacs-outline.next-headline -- in visible buffer
|
|
-- M-x pmacs-outline.parent-headline
|
|
-- M-x pmacs-outline.fold-subtree -- toggle fold of entry
|
|
-- under cursor
|
|
--
|
|
-- Default keybindings on the *visible* buffer (read-only, so these
|
|
-- don't shadow text input):
|
|
--
|
|
-- n next-headline
|
|
-- p parent-headline
|
|
-- Tab fold-subtree
|
|
--
|
|
-- API surface and the M8 ten-API ceiling: outline-class adds 0 new
|
|
-- `pmacs.*` (Rust-bridge) APIs --- the M8.12 ceiling is for the Lua
|
|
-- bindings exposed from Rust, not for package-author Lua surfaces.
|
|
-- The package *does* expose package-level Lua surface: `M.open` and
|
|
-- `M.close` on the entry module, plus the three submodule exports
|
|
-- (parser / view / nav) which the package loader requires in the
|
|
-- manifest's `exports` list so in-package requires resolve. The
|
|
-- function-level contracts on those submodules are package-internal
|
|
-- by convention; external consumers reaching into them are using a
|
|
-- private surface (the `reach-around-require` audit-lint rule and
|
|
-- the SP-3 v0.2-prerequisite address this for v0.2 enforcement).
|
|
-- This is the same disposition the M8.4 and M8.8 audits applied to
|
|
-- dired-class and magit-class respectively.
|
|
|
|
local parser = require("pmacs-outline.parser")
|
|
local view = require("pmacs-outline.view")
|
|
local nav = require("pmacs-outline.nav")
|
|
local aggregate = require("pmacs-outline.aggregate")
|
|
|
|
local M = {}
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- Per-handle state
|
|
-- ---------------------------------------------------------------------------
|
|
|
|
local handles = {}
|
|
|
|
local function find_handle_by(field, buf)
|
|
local live, found = {}, nil
|
|
for _, h in ipairs(handles) do
|
|
local src_ok, src_valid = pcall(h.source.is_valid, h.source)
|
|
local vis_ok, vis_valid = pcall(h.visible.is_valid, h.visible)
|
|
if src_ok and src_valid and vis_ok and vis_valid then
|
|
live[#live + 1] = h
|
|
if h[field] == buf then found = h end
|
|
else
|
|
M.close(h)
|
|
end
|
|
end
|
|
handles = live
|
|
return found
|
|
end
|
|
|
|
local function active_handle()
|
|
return find_handle_by("visible", pmacs.window.buffer())
|
|
end
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- Painting
|
|
-- ---------------------------------------------------------------------------
|
|
--
|
|
-- The visible buffer is read-only via intercept; package paints
|
|
-- bypass via the painting flag. CC-1 pattern.
|
|
|
|
local function paint(handle, text)
|
|
handle.painting = true
|
|
local ok, err = pcall(function()
|
|
handle.visible:replace(0, handle.visible:len(), text)
|
|
end)
|
|
handle.painting = false
|
|
if not ok then error(err) end
|
|
end
|
|
|
|
local function make_readonly_intercept(handle)
|
|
return function(_op)
|
|
if handle.painting then return nil end
|
|
error("pmacs-outline: visible projection is read-only; edit the " ..
|
|
"source buffer instead.")
|
|
end
|
|
end
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- Repaint
|
|
-- ---------------------------------------------------------------------------
|
|
--
|
|
-- Synchronous repaint: read source text, query parser entries
|
|
-- (which triggers any pending lazy reparse), call view.render with
|
|
-- current fold_state, paint the visible buffer.
|
|
--
|
|
-- Repaints set handle.dirty = false on completion. Source edits
|
|
-- set handle.dirty = true via the dirty intercept; lazy callers
|
|
-- (the navigation commands) check handle.dirty and repaint first.
|
|
|
|
local function repaint(handle)
|
|
local src = handle.source
|
|
local source_text = src:slice(0, src:len())
|
|
local entries = parser.entries(handle.parser_handle)
|
|
local proj = view.render(source_text, entries, handle.fold_state)
|
|
handle.projection = proj
|
|
paint(handle, proj.text)
|
|
handle.dirty = false
|
|
end
|
|
|
|
local function repaint_if_dirty(handle)
|
|
if handle.dirty then repaint(handle) end
|
|
end
|
|
|
|
M.__pmacs_outline_test_repaint = repaint -- for the timing test
|
|
M.__pmacs_outline_test_repaint_if_dirty = repaint_if_dirty
|
|
|
|
-- Shift fold-state keys to track entries' byte_start movements
|
|
-- through an edit. fold_state is keyed by the source byte_start of
|
|
-- the folded entry; when the parser shifts an entry through an edit
|
|
-- (insert before / delete before / replace before), the fold key
|
|
-- has to track the same shift or the fold is silently lost on the
|
|
-- next repaint (Pass-3 finding 3).
|
|
--
|
|
-- Rules per op:
|
|
-- insert (pos, n): keys >= pos shift +n.
|
|
-- delete (s, e): keys < s unchanged; keys in [s, e]
|
|
-- dropped (the entry's headline byte
|
|
-- was deleted, so the entry itself
|
|
-- is gone from the cache); keys > e
|
|
-- shift -n where n = e-s.
|
|
-- replace (s, e, n_new): same shape as delete with delta =
|
|
-- n_new - (e-s); keys in [s, e]
|
|
-- dropped.
|
|
--
|
|
-- Edge case for delete at exactly k == s: the byte AT the entry's
|
|
-- byte_start was deleted (i.e., the leading `*` of the headline).
|
|
-- That entry is gone; drop the fold.
|
|
local function shift_fold_keys(handle, op)
|
|
local fs = handle.fold_state
|
|
if next(fs) == nil then return end
|
|
local out = {}
|
|
if op.kind == "insert" then
|
|
local pos, n = op.pos, op.bytes_len
|
|
for k, v in pairs(fs) do
|
|
if k >= pos then out[k + n] = v else out[k] = v end
|
|
end
|
|
elseif op.kind == "delete" then
|
|
local s, e = op.start, op["end"]
|
|
local n = e - s
|
|
for k, v in pairs(fs) do
|
|
if k < s then
|
|
out[k] = v
|
|
elseif k >= e then
|
|
out[k - n] = v
|
|
end
|
|
-- else: k in [s, e), the fold's headline byte was deleted; drop.
|
|
end
|
|
elseif op.kind == "replace" then
|
|
local s, e = op.start, op["end"]
|
|
local delta = op.bytes_len - (e - s)
|
|
for k, v in pairs(fs) do
|
|
if k < s then
|
|
out[k] = v
|
|
elseif k >= e then
|
|
out[k + delta] = v
|
|
end
|
|
end
|
|
end
|
|
handle.fold_state = out
|
|
end
|
|
|
|
-- The dirty-marker intercept on the source buffer. Runs *in addition
|
|
-- to* the parser's own intercept (intercept chain). Both return nil;
|
|
-- both observe the edit. The parser intercept updates cache; this
|
|
-- one marks the visible projection stale and tracks fold-key shifts.
|
|
local function make_dirty_intercept(handle)
|
|
return function(op)
|
|
handle.dirty = true
|
|
shift_fold_keys(handle, op)
|
|
return nil
|
|
end
|
|
end
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- Public-to-package: open / close
|
|
-- ---------------------------------------------------------------------------
|
|
|
|
function M.open(source_buf)
|
|
local existing = find_handle_by("source", source_buf)
|
|
if existing then return existing end
|
|
|
|
local visible = pmacs.buffer.create("*outline:" .. source_buf:name() .. "*")
|
|
local parser_handle = parser.attach(source_buf)
|
|
|
|
local handle = {
|
|
source = source_buf,
|
|
visible = visible,
|
|
parser_handle = parser_handle,
|
|
fold_state = {},
|
|
painting = false,
|
|
dirty = true,
|
|
projection = nil,
|
|
}
|
|
handles[#handles + 1] = handle
|
|
|
|
handle.dirty_intercept = pmacs.buffer.add_intercept(
|
|
source_buf, make_dirty_intercept(handle))
|
|
handle.readonly_intercept = pmacs.buffer.add_intercept(
|
|
visible, make_readonly_intercept(handle))
|
|
|
|
-- Initial render: synchronous so the visible buffer has content
|
|
-- before `open` returns. Bullet 1's 100ms budget covers this path.
|
|
repaint(handle)
|
|
|
|
pmacs.keymap.bind {
|
|
scope = "buffer", buffer = visible, sequence = "n",
|
|
command = "pmacs-outline.next-headline",
|
|
}
|
|
pmacs.keymap.bind {
|
|
scope = "buffer", buffer = visible, sequence = "p",
|
|
command = "pmacs-outline.parent-headline",
|
|
}
|
|
pmacs.keymap.bind {
|
|
scope = "buffer", buffer = visible, sequence = "Tab",
|
|
command = "pmacs-outline.fold-subtree",
|
|
}
|
|
|
|
pmacs.window.switch_buffer(visible)
|
|
return handle
|
|
end
|
|
|
|
function M.close(handle)
|
|
if handle.dirty_intercept then
|
|
pcall(pmacs.buffer.remove_intercept, handle.dirty_intercept)
|
|
handle.dirty_intercept = nil
|
|
end
|
|
if handle.readonly_intercept then
|
|
pcall(pmacs.buffer.remove_intercept, handle.readonly_intercept)
|
|
handle.readonly_intercept = nil
|
|
end
|
|
if handle.parser_handle then
|
|
parser.detach(handle.parser_handle)
|
|
handle.parser_handle = nil
|
|
end
|
|
if handle.visible and pmacs.buffer.kill then
|
|
pcall(pmacs.buffer.kill, handle.visible)
|
|
end
|
|
-- Filter out of `handles`.
|
|
local kept = {}
|
|
for _, x in ipairs(handles) do
|
|
if x ~= handle then kept[#kept + 1] = x end
|
|
end
|
|
handles = kept
|
|
end
|
|
|
|
-- Toggle fold for the entry at the given source byte offset. Used
|
|
-- by the fold-subtree command and exposed for tests.
|
|
function M.toggle_fold(handle, source_byte)
|
|
local entry = parser.entry_at(handle.parser_handle, source_byte)
|
|
if not entry then return end
|
|
if handle.fold_state[entry.byte_start] then
|
|
handle.fold_state[entry.byte_start] = nil
|
|
else
|
|
handle.fold_state[entry.byte_start] = true
|
|
end
|
|
repaint(handle)
|
|
end
|
|
|
|
function M.query(source_buf, predicate)
|
|
if type(predicate) ~= "function" then
|
|
error("pmacs-outline.query: predicate must be a function")
|
|
end
|
|
|
|
local h = find_handle_by("source", source_buf)
|
|
if h then
|
|
return parser.query(h.parser_handle, predicate)
|
|
end
|
|
|
|
local ph = parser.attach(source_buf)
|
|
local ok, result = pcall(function()
|
|
return parser.query(ph, predicate)
|
|
end)
|
|
parser.detach(ph)
|
|
if not ok then error(result) end
|
|
return result
|
|
end
|
|
|
|
pmacs.outline = pmacs.outline or {}
|
|
pmacs.outline.query = M.query
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- Commands
|
|
-- ---------------------------------------------------------------------------
|
|
|
|
local OWNED_COMMANDS = {}
|
|
|
|
local function define_owned(spec)
|
|
pmacs.command.define(spec)
|
|
OWNED_COMMANDS[#OWNED_COMMANDS + 1] = spec.name
|
|
end
|
|
|
|
define_owned {
|
|
name = "pmacs-outline.next-headline",
|
|
description = "Move cursor to the next headline.",
|
|
fn = function()
|
|
local h = active_handle()
|
|
if not h then return end
|
|
repaint_if_dirty(h)
|
|
nav.next_headline(h, parser, view)
|
|
end,
|
|
}
|
|
|
|
define_owned {
|
|
name = "pmacs-outline.parent-headline",
|
|
description = "Move cursor to the parent of the current headline.",
|
|
fn = function()
|
|
local h = active_handle()
|
|
if not h then return end
|
|
repaint_if_dirty(h)
|
|
nav.parent_headline(h, parser, view)
|
|
end,
|
|
}
|
|
|
|
define_owned {
|
|
name = "pmacs-outline.fold-subtree",
|
|
description = "Toggle fold of the subtree under cursor.",
|
|
fn = function()
|
|
local h = active_handle()
|
|
if not h then return end
|
|
repaint_if_dirty(h)
|
|
local cur_visible_line = pmacs.editor.cursor_line()
|
|
local source_byte = view.source_byte_at_visible_line(
|
|
h.projection, cur_visible_line)
|
|
M.toggle_fold(h, source_byte)
|
|
end,
|
|
}
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- Cleanup on unload
|
|
-- ---------------------------------------------------------------------------
|
|
|
|
pmacs.packages.on_unload(function()
|
|
-- Pass-6 finding 2: close live aggregate handles before tearing
|
|
-- down outline view handles. Aggregates hold source-listener
|
|
-- intercepts, parser refcounts, and aggregate buffers that need
|
|
-- the same dispose discipline. Without this, reloading the
|
|
-- package leaves stale intercepts on the user's source buffers
|
|
-- pointing at the old, now-discarded module's closures.
|
|
aggregate.close_all_handles()
|
|
|
|
-- Iterate a snapshot since close() mutates `handles`.
|
|
local snapshot = {}
|
|
for _, h in ipairs(handles) do snapshot[#snapshot + 1] = h end
|
|
for _, h in ipairs(snapshot) do M.close(h) end
|
|
handles = {}
|
|
for _, name in ipairs(OWNED_COMMANDS) do
|
|
pmacs.command.unregister(name)
|
|
end
|
|
OWNED_COMMANDS = {}
|
|
if pmacs.outline and pmacs.outline.query == M.query then
|
|
pmacs.outline.query = nil
|
|
end
|
|
end)
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- Test seam
|
|
-- ---------------------------------------------------------------------------
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- Aggregate buffer (T M8.10)
|
|
-- ---------------------------------------------------------------------------
|
|
|
|
M.aggregate = aggregate.aggregate
|
|
M.aggregate_close = aggregate.aggregate_close
|
|
|
|
M.__pmacs_outline_test_seam_DO_NOT_USE = {
|
|
parser = parser,
|
|
view = view,
|
|
nav = nav,
|
|
aggregate = aggregate,
|
|
active_handle = active_handle,
|
|
find_handle_by = find_handle_by,
|
|
paint = paint,
|
|
repaint = repaint,
|
|
}
|
|
|
|
return M
|