pmacs/tests/fixtures/pmacs-outline/aggregate.lua

492 lines
19 KiB
Lua

-- pmacs-outline/aggregate.lua --- Aggregate buffer over multiple
-- outline sources (T M8.10).
--
-- An aggregate buffer is the second buffer kind shipped by
-- outline-class. It selects matching entries from N source buffers
-- via a predicate, concatenates each entry's source-byte range
-- into a single visible buffer, and routes user edits in the
-- aggregate back to the corresponding source bytes via
-- `intercept_edit`. Source-side edits propagate back to the
-- aggregate within one async tick.
--
-- Architecture:
--
-- * Each source must already have an attached parser (the package
-- entry's `M.attach_buffer` or `parser.attach` does that for
-- outline-source buffers; aggregate.aggregate also re-attaches
-- defensively in case the caller skipped the helper). Without
-- a parser handle we can't query for matching entries.
--
-- * The aggregate's text is `concat(slice(source_i,
-- entry.byte_start, entry.byte_end) for each match)`. Each
-- "block" maps a contiguous aggregate-byte range to (source,
-- source-byte-start). Blocks are recomputed on every render.
--
-- * Write-back intercept on the aggregate routes user edits to
-- source coordinates, then returns nil so the aggregate also
-- applies the same edit locally. Repaint after source change
-- is idempotent: re-rendering produces the same text.
--
-- * Source-listener intercept on each source marks the aggregate
-- dirty and schedules an async repaint via pmacs.async +
-- pmacs.async.yield_to_next_tick(). The body yields once so the
-- source edit's Phase-3 commit completes before repaint reads
-- the source's text.
--
-- * Cross-block edits are rejected. A delete or replace whose
-- range spans more than one block --- for example, deleting
-- bytes that include the boundary between two matching
-- entries from different sources --- has no obvious mapping
-- back to source coordinates and is refused with an explicit
-- error rather than silently dropping bytes.
--
-- * Cycle detection: walks the sources list transitively
-- (following any source that's itself a registered aggregate
-- buffer); rejects if the walk revisits any buffer. v0.1's
-- immutable-sources API can't construct cycles, but the
-- detector is in place for v0.2 mutability and the test seam
-- exercises it.
local parser = require("pmacs-outline.parser")
local M = {}
-- ---------------------------------------------------------------------------
-- Handle registry
-- ---------------------------------------------------------------------------
local handles = {}
local function find_handle_by_buffer(buf)
for _, h in ipairs(handles) do
if h.buffer == buf then return h end
end
return nil
end
-- ---------------------------------------------------------------------------
-- Cycle detection
-- ---------------------------------------------------------------------------
-- Walk `sources` transitively along aggregate-dependency edges.
-- Returns true iff the walk would revisit an aggregate buffer that
-- it's currently inside (a true graph cycle through aggregates).
--
-- Pass-5 finding 3: a previous version used one global visited set
-- and treated every revisit as a cycle. That misclassified
-- duplicate plain sources --- e.g., `aggregate({S, S}, ...)` ---
-- as cycles even though duplicate sources are a legitimate use
-- (and the close path is built to handle them via paired
-- attach/detach calls). The fix: only track *aggregate* buffers
-- on the recursion stack, and only flag a cycle when the walk
-- re-enters an aggregate that's an ancestor in the current path.
-- Plain (non-aggregate) buffers can appear any number of times
-- without triggering the check.
local function would_cycle(sources, self_buffer)
local function walk(srcs, on_path)
for _, s in ipairs(srcs) do
local h = find_handle_by_buffer(s)
if h then
if on_path[s] then return true end
local nested = {}
for k, v in pairs(on_path) do nested[k] = v end
nested[s] = true
if walk(h.sources, nested) then return true end
end
end
return false
end
local initial = {}
if self_buffer then initial[self_buffer] = true end
return walk(sources, initial)
end
M.__pmacs_outline_test_would_cycle = would_cycle
-- ---------------------------------------------------------------------------
-- Render
-- ---------------------------------------------------------------------------
--
-- For each source, run parser.query with the handle's predicate;
-- for each matching entry, slice the source's [byte_start, byte_end)
-- into the aggregate. Returns the concatenated text and a blocks
-- array `{ agg_start, agg_end, source, source_start, source_end,
-- entry }`. Aggregate-byte ranges are 0-indexed and half-open
-- ([agg_start, agg_end)).
local function render(handle)
local parts = {}
local blocks = {}
local agg_pos = 0
for _, src in ipairs(handle.sources) do
local ph = handle.parser_handles[src]
if ph then
local matches = parser.query(ph, handle.predicate)
for _, entry in ipairs(matches) do
local source_text = src:slice(entry.byte_start, entry.byte_end)
parts[#parts + 1] = source_text
blocks[#blocks + 1] = {
agg_start = agg_pos,
agg_end = agg_pos + #source_text,
source = src,
source_start = entry.byte_start,
source_end = entry.byte_end,
entry = entry,
}
agg_pos = agg_pos + #source_text
end
end
end
return table.concat(parts), blocks
end
-- ---------------------------------------------------------------------------
-- Painting
-- ---------------------------------------------------------------------------
local function paint(handle, text)
handle.painting = true
local ok, err = pcall(function()
handle.buffer:replace(0, handle.buffer:len(), text)
end)
handle.painting = false
if not ok then error(err) end
end
local function repaint(handle)
if not handle.alive then return end
local text, blocks = render(handle)
handle.blocks = blocks
paint(handle, text)
handle.dirty = false
end
M.__pmacs_outline_test_repaint = repaint
-- ---------------------------------------------------------------------------
-- Async repaint scheduling
-- ---------------------------------------------------------------------------
--
-- Source intercepts can't repaint synchronously: the source's
-- Phase 3 hasn't committed yet, so reading source:slice would see
-- the pre-edit text. We schedule a coroutine that yields once via
-- pmacs.async.yield_to_next_tick(); the next tick_async resumes it
-- after Phase 3 has applied. `repaint_scheduled` coalesces multiple
-- source edits into one repaint per tick.
--
-- SP-7 resolution: this uses a worker-free next-tick yield primitive,
-- so propagation is pinned to the editor's async tick rather than to
-- a worker reply round trip.
local function schedule_repaint(handle)
if handle.repaint_scheduled or not handle.alive then return end
handle.repaint_scheduled = true
pmacs.async(function()
pmacs.async.yield_to_next_tick()
handle.repaint_scheduled = false
if handle.alive and handle.dirty then
repaint(handle)
end
end)
end
-- ---------------------------------------------------------------------------
-- Write-back intercept on the aggregate
-- ---------------------------------------------------------------------------
-- Half-open `[agg_start, agg_end)` block lookup: used for the START
-- byte of delete/replace ranges. A start byte at exactly an agg_end
-- belongs to the next block (next.agg_start == prev.agg_end).
local function find_block(handle, byte)
for _, b in ipairs(handle.blocks) do
if byte >= b.agg_start and byte < b.agg_end then return b end
end
return nil
end
-- Insert-position lookup (Pass-4 finding 2): for `op.pos == agg_end
-- of a block`, append-to-end semantics map to the LATEST block
-- ending at `pos`. The strict half-open match handles inter-block
-- boundaries (next block wins by half-open semantics); the fallback
-- only fires for pos at the buffer's tail, where there is no next
-- block. This means appending at the very end of the aggregate
-- maps to inserting at `source_end` of the last matched entry,
-- which the parser then rolls into that entry's body on next reparse.
local function find_block_for_insert(handle, pos)
for _, b in ipairs(handle.blocks) do
if b.agg_start <= pos and pos < b.agg_end then return b end
end
local match = nil
for _, b in ipairs(handle.blocks) do
if b.agg_end == pos then match = b end
end
return match
end
local function block_contains_range(b, s, e)
return s >= b.agg_start and e <= b.agg_end
end
local function map_to_source_byte(block, agg_byte)
return block.source_start + (agg_byte - block.agg_start)
end
-- After a writeback edit applies to source S replacing the byte
-- range `[src_s, src_e)` with `delta` net bytes, update
-- handle.blocks (Pass-4 finding 3, Pass-5 finding 2, Pass-6
-- finding 1). For inserts at `pos`, callers pass `src_s = src_e =
-- pos` and `delta = bytes_len`.
--
-- Same-source bookkeeping has five cases against another block B:
--
-- (A) B.source_end <= src_s fully before; no change.
-- (B) B.source_start >= src_e fully after; both source
-- coords shift by delta.
-- (C) B.source_start <= src_s and
-- B.source_end >= src_e B contains the edit
-- range; source_end shifts.
-- (D) edit range fully contains B B's bytes were entirely
-- (E) edit partially overlaps B deleted or replaced;
-- B is invalidated.
--
-- Cases D and E (Pass-6 finding 1) require dropping B from
-- handle.blocks. The deleted/replaced source bytes are gone, so
-- B's source coords no longer point at meaningful content; routing
-- a future write-back through stale B would corrupt the wrong
-- source bytes. The aggregate buffer still carries B's slice
-- (Phase 3 only deletes the user's chosen range), but those bytes
-- are zombie content that the deferred repaint will remove on its
-- next pass; until then `find_block`/`find_block_for_insert`
-- return nothing for that range, so further user edits there are
-- rejected rather than mis-routed. For inserts (src_s == src_e)
-- no block can be "inside" a zero-width range, so D/E never
-- trigger.
--
-- Pass-7 finding 1: the edited block itself follows the same
-- conservative rule. If the edit replaces/deletes the edited
-- block's full source range, the block no longer represents a
-- known matched entry. Drop it instead of leaving a zero-length
-- tail block that `find_block_for_insert` could later pick up via
-- its append-at-end fallback before the deferred repaint runs.
--
-- Aggregate-side shift is unchanged: blocks whose agg_start is at
-- or past the edited block's pre-edit agg_end shift their agg
-- coords by delta. Dropped blocks don't shift (they're gone).
local function shift_blocks_after_writeback(handle, edited_block, src_s, src_e, delta)
if delta == 0 and src_s == src_e then return end
local pre_agg_end = edited_block.agg_end
local edited_invalidated =
src_s < src_e and
src_s <= edited_block.source_start and
src_e >= edited_block.source_end
local kept = {}
for _, b in ipairs(handle.blocks) do
if b == edited_block then
if not edited_invalidated then
kept[#kept + 1] = b
end
else
local invalidated = false
if b.source == edited_block.source then
if b.source_end <= src_s then
-- Case A: fully before.
elseif b.source_start >= src_e then
-- Case B: fully after.
b.source_start = b.source_start + delta
b.source_end = b.source_end + delta
elseif src_s <= b.source_start and src_e >= b.source_end then
-- Case D: edit fully contains B (subset or equal). B's
-- bytes were entirely deleted/replaced; drop.
invalidated = true
elseif b.source_start < src_s and src_e < b.source_end then
-- Case C: B *strictly* contains edit. Body grew/shrank;
-- source_end shifts by delta.
b.source_end = b.source_end + delta
else
-- Case E: partial overlap --- B's boundary is inside the
-- edit range without strict containment in either
-- direction. Drop.
invalidated = true
end
end
if not invalidated then
if b.agg_start >= pre_agg_end then
b.agg_start = b.agg_start + delta
b.agg_end = b.agg_end + delta
end
kept[#kept + 1] = b
end
end
end
handle.blocks = kept
if not edited_invalidated then
edited_block.agg_end = edited_block.agg_end + delta
edited_block.source_end = edited_block.source_end + delta
end
end
local function make_writeback_intercept(handle)
return function(op)
if handle.painting then return nil end
if op.kind == "insert" then
local block = find_block_for_insert(handle, op.pos)
if not block then
error("pmacs-outline.aggregate: insert at byte " .. op.pos ..
" falls outside any matched entry; aggregate edits " ..
"must occur within a matched-entry block.")
end
local src_byte = map_to_source_byte(block, op.pos)
block.source:insert(src_byte, op.bytes)
shift_blocks_after_writeback(handle, block, src_byte, src_byte, op.bytes_len)
elseif op.kind == "delete" then
local s, e = op.start, op["end"]
local block = find_block(handle, s)
if not block or not block_contains_range(block, s, e) then
error("pmacs-outline.aggregate: delete [" .. s .. ", " .. e ..
") spans block boundaries; aggregate edits must stay " ..
"within a single matched-entry block.")
end
local src_s = map_to_source_byte(block, s)
local src_e = map_to_source_byte(block, e)
block.source:delete(src_s, src_e)
shift_blocks_after_writeback(handle, block, src_s, src_e, -(e - s))
elseif op.kind == "replace" then
local s, e = op.start, op["end"]
local block = find_block(handle, s)
if not block or not block_contains_range(block, s, e) then
error("pmacs-outline.aggregate: replace [" .. s .. ", " .. e ..
") spans block boundaries; aggregate edits must stay " ..
"within a single matched-entry block.")
end
local src_s = map_to_source_byte(block, s)
local src_e = map_to_source_byte(block, e)
block.source:replace(src_s, src_e, op.bytes)
shift_blocks_after_writeback(handle, block, src_s, src_e, op.bytes_len - (e - s))
end
handle.dirty = true
return nil
end
end
-- ---------------------------------------------------------------------------
-- Source-listener intercept
-- ---------------------------------------------------------------------------
--
-- Installed on each source buffer the aggregate consumes from. Runs
-- alongside the source's own parser intercept and any
-- outline-handle dirty intercept. Just marks the aggregate dirty
-- and schedules a deferred repaint.
local function make_source_listener(handle)
return function(_op)
handle.dirty = true
schedule_repaint(handle)
return nil
end
end
-- ---------------------------------------------------------------------------
-- Public-to-package: aggregate / aggregate_close
-- ---------------------------------------------------------------------------
function M.aggregate(sources, predicate, opts)
opts = opts or {}
if type(sources) ~= "table" or #sources == 0 then
error("pmacs-outline.aggregate: sources must be a non-empty array of buffers")
end
if type(predicate) ~= "function" then
error("pmacs-outline.aggregate: predicate must be a function")
end
if would_cycle(sources, nil) then
error("pmacs-outline.aggregate: source list contains a cycle through " ..
"another aggregate's buffer chain.")
end
local agg_buf = pmacs.buffer.create(opts.name or "*outline-aggregate*")
local handle = {
buffer = agg_buf,
sources = sources,
predicate = predicate,
parser_handles = {},
blocks = {},
source_listeners = {},
painting = false,
dirty = true,
repaint_scheduled = false,
alive = true,
}
-- Attach the parser to each source (idempotent if already attached)
-- and install the source-listener intercept.
for _, src in ipairs(sources) do
handle.parser_handles[src] = parser.attach(src)
handle.source_listeners[#handle.source_listeners + 1] =
pmacs.buffer.add_intercept(src, make_source_listener(handle))
end
handle.writeback = pmacs.buffer.add_intercept(
agg_buf, make_writeback_intercept(handle))
handles[#handles + 1] = handle
-- Initial render: synchronous so the aggregate is populated before
-- aggregate() returns.
repaint(handle)
return handle
end
function M.aggregate_close(handle)
handle.alive = false
if handle.writeback then
pcall(pmacs.buffer.remove_intercept, handle.writeback)
handle.writeback = nil
end
for _, h in ipairs(handle.source_listeners) do
pcall(pmacs.buffer.remove_intercept, h)
end
handle.source_listeners = {}
-- Pass-4 finding 1: detach the parser handle for each source.
-- We iterate `handle.sources` (not the parser_handles map) so the
-- count of detach calls matches the count of attach calls during
-- aggregate(): a sources list with the same buffer twice produced
-- two attach calls and must produce two detach calls. Refcounting
-- in parser.attach/detach ensures we don't tear down a parser
-- that another outline view or aggregate still holds.
for _, src in ipairs(handle.sources) do
local ph = handle.parser_handles[src]
if ph then parser.detach(ph) end
end
handle.parser_handles = {}
if handle.buffer and pmacs.buffer.kill then
pcall(pmacs.buffer.kill, handle.buffer)
end
local kept = {}
for _, x in ipairs(handles) do
if x ~= handle then kept[#kept + 1] = x end
end
handles = kept
end
-- Close every live aggregate handle (Pass-6 finding 2). Used by
-- the package's on_unload hook so a `pmacs.packages.reload` call
-- doesn't leave source-listener intercepts, parser refcounts, and
-- aggregate buffers attached after the old package's tables are
-- torn down. Safe to call multiple times --- aggregate_close
-- removes the handle from the registry.
function M.close_all_handles()
local snapshot = {}
for _, h in ipairs(handles) do snapshot[#snapshot + 1] = h end
for _, h in ipairs(snapshot) do M.aggregate_close(h) end
handles = {}
end
-- Test seam: expose the handles registry so tests can manually
-- construct cycle scenarios that the v0.1 API can't reach.
M.__pmacs_outline_test_handles = function() return handles end
return M