hephaestus/heph.nvim/lua/heph/frontmatter.lua
Erich Blume 8dc98dc9c1
Some checks failed
Build / validate (pull_request) Failing after 4m57s
feat(nvim): inline #hashtags become tags on save (§8.3)
On save, whitespace-prefixed `#hashtags` in a node's body are unioned
into its tag set (via `frontmatter.hashtags` + the existing tag diff), so
you can tag a note by writing `#kitchen` inline. A markdown `# heading`
has a space after the `#`, so it never matches. e2e covers it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 11:57:25 -07:00

205 lines
6.2 KiB
Lua
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

--- Frontmatter as an edit surface (tech-spec §8.3). The daemon renders an
--- editable YAML block atop a node's body on read (`node.get {frontmatter}`)
--- and strips it on write; this module is the **smart-client** half: parse the
--- buffer's frontmatter and translate each changed field into the right
--- structured RPC (title→rename, attention→set_attention, dates→set_schedule,
--- project→set_project, tags→tag.add/remove). The body itself rides `node.update`.
local rpc = require("heph.rpc")
local M = {}
local function trim(s)
return (s:gsub("^%s+", ""):gsub("%s+$", ""))
end
--- Unquote a YAML scalar (`"…"` with `\"`/`\\` escapes), else return it trimmed.
local function unquote(s)
s = trim(s)
local inner = s:match('^"(.*)"$')
if inner then
return (inner:gsub('\\"', '"'):gsub("\\\\", "\\"))
end
return s
end
--- Parse a YAML flow sequence `[a, b]` (or `[]`) into a list of scalars.
local function parse_flow_seq(s)
local inner = s:match("^%[(.*)%]$")
if not inner then
return {}
end
local out = {}
for item in (inner .. ","):gmatch("(.-),") do
item = trim(item)
if item ~= "" then
out[#out + 1] = unquote(item)
end
end
return out
end
--- Parse a leading `---` frontmatter block. Returns `(fm, body)` where `fm` is a
--- table of string fields plus `tags` (a list), or `(nil, text)` when there is
--- no conforming block (so a frontmatter-less buffer never looks like metadata).
function M.parse(text)
if text:sub(1, 4) ~= "---\n" then
return nil, text
end
local lines = vim.split(text, "\n", { plain = true })
local close
for i = 2, #lines do
if lines[i] == "---" then
close = i
break
end
end
if not close then
return nil, text
end
local fm = { tags = {} }
for j = 2, close - 1 do
local key, val = lines[j]:match("^([%w_%-]+):%s*(.*)$")
if key == "tags" then
fm.tags = parse_flow_seq(val)
elseif key then
fm[key] = unquote(val)
end
end
local body = table.concat({ unpack(lines, close + 1) }, "\n")
return fm, body
end
--- A `YYYY-MM-DD` string → epoch ms at **local midnight** (matching the
--- daemon's `datespec`). Returns `nil, err` on a malformed date.
local function date_to_ms(s)
local y, m, d = tostring(s):match("^(%d%d%d%d)%-(%d%d)%-(%d%d)$")
if not y then
return nil, "expected a YYYY-MM-DD date, got " .. tostring(s)
end
return os.time({ year = tonumber(y), month = tonumber(m), day = tonumber(d), hour = 0, min = 0, sec = 0 }) * 1000
end
--- Whitespace-prefixed inline `#hashtags` in `body`, de-duplicated in order.
--- A markdown heading (`# Title`, `## foo`) has a space after the `#`, so it
--- never matches. (Scanned across the whole body, code fences included — a
--- pragmatic v1 simplification.)
function M.hashtags(body)
local seen, out = {}, {}
for tag in (" " .. (body or "")):gmatch("%s#([%w_%-]+)") do
if not seen[tag] then
seen[tag] = true
out[#out + 1] = tag
end
end
return out
end
--- Union of two scalar lists, order-stable, de-duplicated.
local function union(a, b)
local seen, out = {}, {}
for _, list in ipairs({ a or {}, b or {} }) do
for _, x in ipairs(list) do
if not seen[x] then
seen[x] = true
out[#out + 1] = x
end
end
end
return out
end
--- Set difference of two scalar lists → (added, removed).
local function set_diff(old, new)
local oldset, newset = {}, {}
for _, x in ipairs(old or {}) do
oldset[x] = true
end
for _, x in ipairs(new or {}) do
newset[x] = true
end
local added, removed = {}, {}
for x in pairs(newset) do
if not oldset[x] then
added[#added + 1] = x
end
end
for x in pairs(oldset) do
if not newset[x] then
removed[#removed + 1] = x
end
end
return added, removed
end
--- Apply the difference between `canonical` (rendered on read) and the buffer's
--- `fm` by issuing the matching RPCs. `node_id` is the opened node (title/tags
--- target); task scalars route to the owning task (`fm.task`). Inline
--- `#hashtags` in `body` are unioned into the desired tag set (so they manage
--- tags too); `fm.tags` is updated to that union so the caller caches it as the
--- new canonical. Raises on any RPC error (a mistyped `state`, an unknown
--- `project`).
function M.apply(node_id, canonical, fm, body)
canonical = canonical or {}
-- Tags on the opened node = the frontmatter list inline #hashtags.
fm.tags = union(fm.tags, M.hashtags(body))
local added, removed = set_diff(canonical.tags, fm.tags)
for _, t in ipairs(added) do
rpc.call("tag.add", { node_id = node_id, tag = t })
end
for _, t in ipairs(removed) do
rpc.call("tag.remove", { node_id = node_id, tag = t })
end
-- Task scalars route to the owning task, if this node is or backs one.
local task = fm.task or canonical.task
if task then
if fm.attention and fm.attention ~= canonical.attention then
rpc.call("task.set_attention", { id = task, attention = fm.attention })
end
-- Schedule (double-option): a present value sets, a removed line clears.
local patch, changed = { id = task }, false
local function sched(field)
if fm[field] == canonical[field] then
return
end
changed = true
if fm[field] == nil or fm[field] == "" then
patch[field] = vim.NIL -- cleared
elseif field == "recurrence" then
patch[field] = fm[field] -- a raw RRULE
else
local ms, err = date_to_ms(fm[field])
if not ms then
error("heph: " .. err)
end
patch[field] = ms
end
end
sched("do_date")
sched("late_on")
sched("recurrence")
if changed then
rpc.call("task.set_schedule", patch)
end
if fm.state and fm.state ~= canonical.state then
rpc.call("task.set_state", { id = task, state = fm.state }) -- a typo → rpc error
end
if fm.project ~= canonical.project then
local pid = vim.NIL -- removed / blank → unfile
if fm.project and fm.project ~= "" then
local node = rpc.call("node.resolve", { title = fm.project })
if not node then
error("heph: no node named '" .. fm.project .. "' to file under")
end
pid = node.id
end
rpc.call("task.set_project", { id = task, project_id = pid })
end
end
end
return M