699 lines
25 KiB
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
|