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

699 lines
25 KiB
Lua

-- pmacs-outline/parser.lua --- Org-shaped headline parser with
-- lazy-incremental cache (T M8.9).
--
-- Module-internal load-bearing piece of pmacs-outline. Exposes a
-- parse-and-query surface that init.lua and nav.lua call directly,
-- and that M8.10's aggregate buffer will consume from the same
-- package without crossing the public-API boundary.
--
-- Cache policy: **lazy-reparse-on-query** (option (b) from the M8.9
-- design discussion). The intercept attached to the source buffer
-- observes edits and stashes the affected byte range as a "dirty"
-- region; reparse does not run inside the intercept. The next call
-- to `query` (or any cache-reading helper) reparses dirty regions
-- only, splices the new entries into the cache, then evaluates the
-- predicate against the now-fresh entries.
--
-- Implications:
--
-- * An edit followed by no query is free (zero reparse calls).
-- * Multiple edits between two queries coalesce into one reparse
-- per affected byte region (overlapping regions merge).
-- * The reparse boundary is the *containing subtree* of the edit,
-- extended forward to the next equal-or-higher-level headline
-- (or buffer end). Edits outside any subtree (e.g., the buffer
-- prelude before the first headline) reparse from edit-start to
-- the first headline.
-- * This *does not* satisfy edits that cross multiple subtrees
-- (e.g., a wholesale paste that contains its own headline
-- hierarchy). For those, the affected region falls back to
-- [edit_start, EOF], which is still correct (just slower).
-- * `query` evaluates the predicate against every cached entry
-- after reparse; predicates that target only one subtree still
-- reparse every dirty region (not just the targeted one). This
-- is fine for the M8.9 acceptance: the test corpus measures
-- parse-call count, not predicate-evaluation cost, and the
-- count increments by exactly one per query that finds dirty
-- regions to reparse.
--
-- Org-shape subset (per the M8.9 spec, intentionally narrow):
--
-- * Headline line: `^\*+ ` at column 0, level = number of `*`s.
-- * Trailing tags: ` :tag1:tag2:` at end of headline line; tags
-- are [A-Za-z0-9_]+, separated by `:`.
-- * Properties drawer: lines after a headline, opened by a line
-- equal to `:PROPERTIES:` and closed by `:END:`. Inside, lines
-- of the form `:KEY: value` populate the entry's properties
-- table.
-- * Body: any non-headline lines after a headline (and after
-- the closing `:END:` if a properties drawer was present).
--
-- Compatibility with arbitrary org-mode files is *not a goal*. A
-- file using only this subset parses correctly; richer org features
-- (`#+TITLE:` lines, source blocks, agenda markers, inline emphasis
-- markup) are ignored as plain body text.
local M = {}
-- ---------------------------------------------------------------------------
-- Test seam: parse-call counter
-- ---------------------------------------------------------------------------
--
-- Increments by 1 every time `parse_region` runs. The M8.9
-- acceptance test asserts the counter increments by 1 per query
-- (not per edit) when there are dirty regions, and by 0 per query
-- when there are none --- evidence of the lazy-incremental policy.
-- Loud-named per the SP-3 convention (M8.4 audit finding 7).
local parse_count = 0
M.__pmacs_outline_test_parse_count = function() return parse_count end
M.__pmacs_outline_test_reset_parse_count = function() parse_count = 0 end
-- ---------------------------------------------------------------------------
-- Region parser
-- ---------------------------------------------------------------------------
--
-- Parses a chunk of text starting at byte offset `base` (in the
-- containing buffer's coordinate space). Returns an array of
-- entries with byte ranges relative to `base`.
--
-- The region's text is taken at face value: the region is assumed
-- to begin at a fresh line boundary (start-of-buffer or just after
-- a newline). Callers ensure this by snapping the affected region
-- to a headline boundary before invoking parse_region.
local function tag_split(s)
-- s is a colon-bracketed run like ":tag1:tag2:" with no whitespace.
local out = {}
local i = 2 -- skip the leading colon
while i <= #s do
local nx = s:find(":", i, true)
if not nx then break end
if nx > i then out[#out + 1] = s:sub(i, nx - 1) end
i = nx + 1
end
return out
end
local function parse_headline(line)
-- line is the raw headline text, including the leading stars and space.
-- Returns (level, title, tags) where tags is an array (possibly empty).
local stars = line:match("^(%*+) ")
if not stars then return nil end
local level = #stars
local rest = line:sub(level + 2) -- drop "*+ " prefix
local tags = {}
-- Trailing-tag pattern: at end of line, a colon-bracketed run of
-- alphanumeric/underscore tag names separated by colons, preceded
-- by at least one space. Lua patterns don't support `+` on groups,
-- so we match a single colon-bracketed run --- the character class
-- includes `:` so multiple tags joined by colons (`:tag1:tag2:`)
-- are captured as one substring; tag_split then parses out the
-- individual names.
local tag_run_start, tag_run = rest:match("()%s+(:[%w_:]+:)%s*$")
if tag_run_start then
tags = tag_split(tag_run)
rest = rest:sub(1, tag_run_start - 1)
end
-- Trim trailing whitespace from title.
rest = rest:gsub("%s+$", "")
return level, rest, tags
end
-- Parses `text` (the slice from `base` to `base + #text`) into an
-- array of entries with absolute byte_start / byte_end fields.
-- byte_end of each entry is the start of the next equal-or-higher
-- headline (or `base + #text` for the last subtree). headline_byte_end
-- is the byte just past the headline line's newline (or text-end if
-- there's no trailing newline).
local function parse_region(text, base)
parse_count = parse_count + 1
local entries = {}
local len = #text
local i = 1
while i <= len do
-- Find the end of the current line.
local lf = text:find("\n", i, true)
local line_end = lf and (lf - 1) or len
local line = text:sub(i, line_end)
if line:sub(1, 1) == "*" and parse_headline(line) then
local level, title, tags = parse_headline(line)
local entry = {
byte_start = base + (i - 1),
headline_byte_end = base + (lf or (len + 1)),
level = level,
title = title,
tags = tags,
properties = {},
}
-- Tag-set lookup for cheap predicate use.
local tagset = {}
for _, t in ipairs(tags) do tagset[t] = true end
entry.tagset = tagset
-- Walk forward into the body to capture an optional :PROPERTIES:
-- drawer. Only the FIRST non-empty content block is inspected;
-- a properties drawer must immediately follow the headline (per
-- org convention). Body lines after the drawer or after the
-- headline (if no drawer) are not stored --- the parser exposes
-- the byte range; consumers slice the buffer for content.
local probe = lf and (lf + 1) or (len + 1)
if probe <= len then
local pe = text:find("\n", probe, true)
local probe_end = pe and (pe - 1) or len
local probe_line = text:sub(probe, probe_end)
if probe_line == ":PROPERTIES:" then
local pi = pe and (pe + 1) or (len + 1)
while pi <= len do
local nx = text:find("\n", pi, true)
local nxe = nx and (nx - 1) or len
local pl = text:sub(pi, nxe)
if pl == ":END:" then
break
end
local key, value = pl:match("^:([%w_%-]+):%s*(.-)%s*$")
if key then entry.properties[key] = value end
pi = nx and (nx + 1) or (len + 1)
end
end
end
entries[#entries + 1] = entry
i = lf and (lf + 1) or (len + 1)
else
-- Non-headline line; advance.
i = lf and (lf + 1) or (len + 1)
end
end
-- Compute byte_end for each entry: start of next equal-or-higher
-- headline, or `base + len` for the last entry of the region.
local n = #entries
for idx = 1, n do
local e = entries[idx]
local end_byte = base + len
for j = idx + 1, n do
if entries[j].level <= e.level then
end_byte = entries[j].byte_start
break
end
end
e.byte_end = end_byte
end
return entries
end
M.__pmacs_outline_test_parse_region = parse_region -- exposed for unit tests
-- ---------------------------------------------------------------------------
-- Handle / cache
-- ---------------------------------------------------------------------------
--
-- A handle wraps a buffer and tracks the parser cache + dirty
-- regions. Created via `parser.attach(buf)` once per outline
-- buffer; stored in a module-private table keyed by buffer id.
local handles = {} -- list, walked for is_valid sweeps
local function find_handle(buf)
local live, found = {}, nil
for _, h in ipairs(handles) do
local ok, valid = pcall(h.buffer.is_valid, h.buffer)
if ok and valid then
live[#live + 1] = h
if h.buffer == buf then found = h end
else
if h.intercept then
pcall(pmacs.buffer.remove_intercept, h.intercept)
h.intercept = nil
end
end
end
handles = live
return found
end
-- Coalesce adjacent / overlapping byte ranges. Each range is
-- {start, end} in post-edit buffer coordinates. The resulting list
-- is sorted by start with no overlaps.
local function coalesce_ranges(rs)
if #rs <= 1 then return rs end
table.sort(rs, function(a, b) return a[1] < b[1] end)
local out = { rs[1] }
for i = 2, #rs do
local last = out[#out]
local cur = rs[i]
if cur[1] <= last[2] then
if cur[2] > last[2] then last[2] = cur[2] end
else
out[#out + 1] = cur
end
end
return out
end
-- Snap an affected byte range to subtree boundaries against the
-- current cache. Returns {start, end} where:
-- * start is the byte_start of the cached entry that contains
-- `range_start`, or 0 if `range_start` falls before any
-- headline.
-- * end is the byte_end of the cached entry whose subtree extends
-- past `range_end`, or `range_end` if no later entry exists.
--
-- Subtree-aligned snapping ensures parse_region sees a chunk that
-- begins at a fresh line and contains complete headlines.
--
-- Three-stage extension to handle structural edits (Pass-3 finding 2):
--
-- 1. Containing entries: if a cached entry brackets `range_start`
-- or `range_end`, snap to its boundaries (the original logic).
-- 2. Backward fallback: if no entry contains `range_start`, snap
-- to the byte_end of the latest entry strictly before it (or 0
-- if none) --- the previous structural boundary.
-- 3. Forward fallback: if `snap_end` is still <= `range_end`, snap
-- to the byte_start of the next entry strictly after (or
-- `total` if none) --- the next structural boundary.
--
-- Stages 2 and 3 catch the "structural merge" case: an edit that
-- deletes the boundary between two headlines (e.g., the `\n`
-- between `* A` and `* B`) drops both entries' cache, leaving the
-- dirty range surrounded by no entries; we then must reparse all
-- the way to the next surviving structural marker, which may be
-- end-of-buffer.
local function snap_to_subtree(cache, range_start, range_end, total)
local snap_start, snap_end = 0, range_end
local found_containing_start = false
for _, e in ipairs(cache.entries) do
if e.byte_start <= range_start and e.byte_end >= range_start then
snap_start = e.byte_start
if e.byte_end > snap_end then snap_end = e.byte_end end
found_containing_start = true
end
if e.byte_start <= range_end and e.byte_end > snap_end then
snap_end = e.byte_end
end
end
if not found_containing_start then
local backward_end = 0
for _, e in ipairs(cache.entries) do
if e.byte_end <= range_start and e.byte_end > backward_end then
backward_end = e.byte_end
end
end
snap_start = backward_end
end
if snap_end <= range_end then
local forward_start = total
local found = false
for _, e in ipairs(cache.entries) do
if e.byte_start > range_end and (not found or e.byte_start < forward_start) then
forward_start = e.byte_start
found = true
end
end
snap_end = forward_start
end
return snap_start, snap_end
end
-- Recompute every entry's byte_end as the byte_start of the next
-- entry whose level is <= the entry's own level (or `total` for the
-- last subtree). This is the same derivation parse_region does at
-- the end of a parse; we run it after every shift so that snapping
-- a dirty region against the cache sees consistent byte_end values.
local function recompute_byte_ends(entries, total)
local n = #entries
for idx = 1, n do
local ent = entries[idx]
local end_byte = total
for j = idx + 1, n do
if entries[j].level <= ent.level then
end_byte = entries[j].byte_start
break
end
end
ent.byte_end = end_byte
end
end
-- Mark a byte range as dirty (post-edit coordinates); the caller has
-- already adjusted entries via the per-op update helpers.
local function record_dirty(cache, byte_start, byte_end)
cache.dirty[#cache.dirty + 1] = { byte_start, byte_end }
cache.dirty = coalesce_ranges(cache.dirty)
end
-- Per-op cache updaters. Each:
-- 1. drops entries that intersect the edit (their content changed
-- in a way the cache can't trust without reparsing);
-- 2. shifts byte_start / headline_byte_end of surviving entries
-- that begin at or after the edit endpoint;
-- 3. for an entry whose subtree fully *contains* the edit (the
-- ancestor case), keeps the entry --- but its byte_end is now
-- stale and must be recomputed after the loop;
-- 4. records the dirty range in post-edit coordinates;
-- 5. recomputes every byte_end from the surviving byte_starts and
-- the new total buffer length.
--
-- Step 3's "contains the edit" check uses *headline_byte_end* as the
-- cutoff: an edit before headline_byte_end touches the headline line
-- itself (the title, tag run, or stars), which would invalidate
-- title/tags/level on the cached entry --- drop it. An edit at or
-- after headline_byte_end is in the body, so the headline metadata
-- survives; keep the entry. This is what the M8.9 Pass-2 finding-2
-- test asserts (deleting inside a property drawer must not lose
-- properties for the *enclosing* headline; the enclosing headline
-- survives because the edit is past its headline_byte_end).
local function update_for_insert(cache, pos, n)
local kept = {}
for _, ent in ipairs(cache.entries) do
if ent.byte_start >= pos then
-- Strictly after the insertion point: shift everything by +n.
ent.byte_start = ent.byte_start + n
ent.headline_byte_end = ent.headline_byte_end + n
ent.byte_end = ent.byte_end + n
kept[#kept + 1] = ent
elseif ent.byte_end <= pos then
-- Strictly before: no shift.
kept[#kept + 1] = ent
else
-- Insertion is inside this entry's subtree.
if pos < ent.headline_byte_end then
-- Insertion within (or just before) the headline line: title
-- /tags/level are now suspect; drop and reparse.
else
-- Insertion in body of this ancestor; keep, byte_end stale
-- (will be recomputed below).
kept[#kept + 1] = ent
end
end
end
cache.entries = kept
-- Dirty range: cover the inserted bytes in post-edit coordinates.
record_dirty(cache, pos, pos + n)
end
local function update_for_delete(cache, s, e)
local n = e - s
local kept = {}
for _, ent in ipairs(cache.entries) do
if ent.byte_end <= s then
kept[#kept + 1] = ent -- fully before
elseif ent.byte_start >= e then
if ent.byte_start == e then
-- The byte at byte_start - 1 (= e - 1) was deleted. That
-- byte was the headline line's preceding newline (or BOF
-- alignment). The entry's line-start position is no longer
-- structurally valid: drop and let reparse re-discover.
-- Pass-3 finding 2.
else
ent.byte_start = ent.byte_start - n
ent.headline_byte_end = ent.headline_byte_end - n
ent.byte_end = ent.byte_end - n
kept[#kept + 1] = ent
end
elseif ent.byte_start < s and ent.byte_end >= e then
-- Deletion fully inside this entry's subtree.
if s < ent.headline_byte_end then
-- Deletion crosses into the headline line; drop.
else
-- Deletion in body; keep ancestor with stale byte_end.
kept[#kept + 1] = ent
end
else
-- Partial overlap: drop and reparse.
end
end
cache.entries = kept
record_dirty(cache, s, s)
end
local function update_for_replace(cache, s, e, n_new)
local n_old = e - s
local delta = n_new - n_old
local kept = {}
for _, ent in ipairs(cache.entries) do
if ent.byte_end <= s then
kept[#kept + 1] = ent
elseif ent.byte_start >= e then
if ent.byte_start == e then
-- Same as delete's edge case: byte at byte_start - 1 was in
-- the replaced range, so the entry's preceding-newline byte
-- is now arbitrary content. Drop.
else
ent.byte_start = ent.byte_start + delta
ent.headline_byte_end = ent.headline_byte_end + delta
ent.byte_end = ent.byte_end + delta
kept[#kept + 1] = ent
end
elseif ent.byte_start < s and ent.byte_end >= e then
-- Replacement fully inside this entry's subtree.
if s < ent.headline_byte_end then
-- Crosses into headline; drop.
else
-- In body; keep ancestor with stale byte_end.
kept[#kept + 1] = ent
end
else
-- Partial overlap: drop.
end
end
cache.entries = kept
record_dirty(cache, s, s + n_new)
end
-- The intercept callback: observes edits, returns nil so the edit
-- proceeds, and updates the cache's dirty regions + byte deltas.
-- Runs *before* the rope mutates, so byte ranges in the op are in
-- pre-edit coordinates.
local function make_intercept(handle)
return function(op)
if handle.painting then return nil end
local cache = handle.cache
local total = handle.buffer:len()
if op.kind == "insert" then
update_for_insert(cache, op.pos, op.bytes_len)
total = total + op.bytes_len -- post-edit length
elseif op.kind == "delete" then
update_for_delete(cache, op.start, op["end"])
total = total - (op["end"] - op.start)
elseif op.kind == "replace" then
update_for_replace(cache, op.start, op["end"], op.bytes_len)
total = total + op.bytes_len - (op["end"] - op.start)
end
-- Recompute byte_end against the new (post-edit) total length so
-- that the next snap_to_subtree sees a consistent cache.
recompute_byte_ends(cache.entries, total)
return nil
end
end
-- Reparse all dirty regions, splicing fresh entries into the cache.
-- Increments parse_count once per region (post-coalesce). Idempotent
-- when there are no dirty regions.
local function reparse_dirty(handle)
local cache = handle.cache
if #cache.dirty == 0 and cache.entries_seeded then return end
local buf = handle.buffer
local total = buf:len()
if not cache.entries_seeded then
-- Initial parse: whole buffer.
cache.entries = parse_region(buf:slice(0, total), 0)
cache.entries_seeded = true
cache.dirty = {}
return
end
-- Snap each dirty range to subtree boundaries against the current cache,
-- then coalesce again (snapping may extend ranges to overlap).
local snapped = {}
for _, r in ipairs(cache.dirty) do
local s, e = snap_to_subtree(cache, r[1], r[2], total)
if e > total then e = total end
if s < 0 then s = 0 end
snapped[#snapped + 1] = { s, e }
end
snapped = coalesce_ranges(snapped)
for _, r in ipairs(snapped) do
local s, e = r[1], r[2]
-- Snap region forward to a headline boundary if the next cached
-- entry beyond `e` exists with byte_start > e. We extend `e`
-- only if a partial headline would otherwise be cut.
local chunk = buf:slice(s, e)
local fresh = parse_region(chunk, s)
-- Splice: drop cached entries fully within [s, e); insert fresh.
local kept_before, kept_after = {}, {}
for _, ent in ipairs(cache.entries) do
if ent.byte_end <= s then
kept_before[#kept_before + 1] = ent
elseif ent.byte_start >= e then
kept_after[#kept_after + 1] = ent
else
-- fully or partially within: drop (parse output replaces)
end
end
local merged = {}
for _, x in ipairs(kept_before) do merged[#merged + 1] = x end
for _, x in ipairs(fresh) do merged[#merged + 1] = x end
for _, x in ipairs(kept_after) do merged[#merged + 1] = x end
cache.entries = merged
end
-- After reparse, recompute byte_end for every entry: it depends on
-- the FULL list of entries (next equal-or-higher level), so a
-- partial reparse can't fix it locally.
local n = #cache.entries
for idx = 1, n do
local ent = cache.entries[idx]
local end_byte = total
for j = idx + 1, n do
if cache.entries[j].level <= ent.level then
end_byte = cache.entries[j].byte_start
break
end
end
ent.byte_end = end_byte
end
cache.dirty = {}
end
-- ---------------------------------------------------------------------------
-- Public-to-package API
-- ---------------------------------------------------------------------------
-- Attach is refcounted (Pass-4 finding 1): each call to attach for
-- the same buffer returns the existing handle and bumps `refcount`;
-- each call to detach decrements; the intercept and cache are torn
-- down only when the count reaches zero. Without this, an outline
-- view and an aggregate buffer that share a source would either (a)
-- duplicate the parser intercept (extra cache work, double-fire) or
-- (b) tear down the parser when the first consumer closes, breaking
-- the second.
function M.attach(buf)
local existing = find_handle(buf)
if existing then
existing.refcount = existing.refcount + 1
return existing
end
local handle = {
buffer = buf,
cache = {
entries = {},
entries_seeded = false,
dirty = {},
},
painting = false,
refcount = 1,
}
handle.intercept = pmacs.buffer.add_intercept(buf, make_intercept(handle))
handles[#handles + 1] = handle
return handle
end
function M.detach(handle)
handle.refcount = (handle.refcount or 1) - 1
if handle.refcount > 0 then return end
if handle.intercept then
pcall(pmacs.buffer.remove_intercept, handle.intercept)
handle.intercept = nil
end
-- Pass-5 finding 1: remove the handle from `handles` immediately
-- when refcount drops to 0. Leaving it around lets a subsequent
-- parser.attach(buf) for the same buffer find the dead handle,
-- bump its refcount, and return it without reinstalling the
-- intercept --- the cache then never invalidates on edits and
-- queries see stale data.
local kept = {}
for _, h in ipairs(handles) do
if h ~= handle then kept[#kept + 1] = h end
end
handles = kept
end
-- Test seam: report the current refcount for `handle` (or nil if
-- the handle has been fully torn down).
M.__pmacs_outline_test_refcount = function(handle)
return handle.refcount
end
function M.entries(handle)
reparse_dirty(handle)
return handle.cache.entries
end
function M.query(handle, predicate)
reparse_dirty(handle)
local out = {}
for _, e in ipairs(handle.cache.entries) do
if predicate(e) then out[#out + 1] = e end
end
return out
end
function M.entry_at(handle, byte_offset)
reparse_dirty(handle)
for _, e in ipairs(handle.cache.entries) do
if e.byte_start <= byte_offset and byte_offset < e.byte_end then
-- Walk forward to find the deepest entry containing byte_offset.
local deepest = e
for _, candidate in ipairs(handle.cache.entries) do
if candidate.byte_start <= byte_offset and byte_offset < candidate.byte_end
and candidate.level > deepest.level then
deepest = candidate
end
end
return deepest
end
end
return nil
end
-- Parent of `entry`: the nearest preceding entry with strictly
-- lower level. nil if `entry` is top-level.
function M.parent(handle, entry)
reparse_dirty(handle)
local prev = nil
for _, e in ipairs(handle.cache.entries) do
if e == entry then return prev end
if e.level < entry.level then prev = e end
end
return nil
end
-- Next sibling-or-uncle: the nearest following entry whose level is
-- less-than-or-equal to `entry.level`. nil if none. This is the
-- "skip past subtree" target for fold-subtree.
function M.next_sibling_or_uncle(handle, entry)
reparse_dirty(handle)
local seen = false
for _, e in ipairs(handle.cache.entries) do
if seen and e.level <= entry.level then return e end
if e == entry then seen = true end
end
return nil
end
-- Next entry in DFS order (any level). Used by next-headline.
function M.next_headline(handle, byte_offset)
reparse_dirty(handle)
for _, e in ipairs(handle.cache.entries) do
if e.byte_start > byte_offset then return e end
end
return nil
end
return M