generated from eblume/project-template
heph.nvim: RPC client + buffer editing + wiki-links + journal (slice 11a)
Some checks failed
Build / validate (pull_request) Failing after 4s
Some checks failed
Build / validate (pull_request) Failing after 4s
The primary surface begins (tech-spec §8): a Neovim plugin that is a thin
client of the local hephd over its unix-socket JSON-RPC.
- node.resolve {title} → Node|null (heph-core Store + dispatch): exact,
owner-scoped, non-tombstoned alias-then-title match — the same mapping that
materializes wiki links, so follow-link jumps to the node the stored link
points at (never fuzzy search). Unit + rpc_socket integration tests.
- heph.nvim/: vim.uv unix-socket JSON-RPC client (blocking call via vim.wait,
id-demuxed, partial-line buffered, luanil so JSON null → Lua nil; isolated
Sessions for tests). Buffer-backed nodes (heph://node/<id>, acwrite;
BufReadCmd→node.get / BufWriteCmd→node.update, whole-buffer body round-trips
exactly through the CRDT). [[wiki-link]] follow on <CR>. Daily journal.
:Heph command surface + completion.
- Headless e2e (§9): a self-contained busted-style runner (tests/e2e/runner.lua)
— no external plugins, no network, deterministic CI exit codes. Specs: journal
round-trip, follow-link (+ unresolved no-op), link-two-docs/backlink.
`make -C heph.nvim test` builds hephd and runs it.
Docs: heph-nvim reference card, §14 tracker (11a done; 11b/11c/11d queued),
changelog fragment.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
87c76da659
commit
ee865e5635
29 changed files with 1240 additions and 10 deletions
62
heph.nvim/lua/heph/command.lua
Normal file
62
heph.nvim/lua/heph/command.lua
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
--- The `:Heph <subcommand>` user-command surface. Tactical/Organizational task
|
||||
--- views (next/list/capture/...) arrive with slice 11b; this is the knowledge-
|
||||
--- base core (journal, links).
|
||||
|
||||
local M = {}
|
||||
|
||||
--- subcommand -> handler(args: string[])
|
||||
M.subs = {
|
||||
today = function()
|
||||
require("heph.journal").open()
|
||||
end,
|
||||
journal = function(args)
|
||||
require("heph.journal").open(args[1])
|
||||
end,
|
||||
follow = function()
|
||||
require("heph.link").follow()
|
||||
end,
|
||||
open = function(args)
|
||||
if args[1] then
|
||||
require("heph.node").open(args[1])
|
||||
end
|
||||
end,
|
||||
}
|
||||
|
||||
--- `:Heph` entry point.
|
||||
function M.run(opts)
|
||||
local args = opts.fargs
|
||||
local sub = args[1]
|
||||
if not sub then
|
||||
require("heph.util").notify("usage: :Heph <" .. table.concat(M.names(), "|") .. ">", vim.log.levels.WARN)
|
||||
return
|
||||
end
|
||||
local handler = M.subs[sub]
|
||||
if not handler then
|
||||
require("heph.util").notify("unknown subcommand: " .. sub, vim.log.levels.ERROR)
|
||||
return
|
||||
end
|
||||
local ok, err = pcall(handler, vim.list_slice(args, 2))
|
||||
if not ok then
|
||||
require("heph.util").notify(tostring(err), vim.log.levels.ERROR)
|
||||
end
|
||||
end
|
||||
|
||||
--- Sorted subcommand names.
|
||||
function M.names()
|
||||
local names = vim.tbl_keys(M.subs)
|
||||
table.sort(names)
|
||||
return names
|
||||
end
|
||||
|
||||
--- Completion: subcommand names at the first position.
|
||||
function M.complete(arglead, cmdline, _cursorpos)
|
||||
-- Only complete the subcommand token (first arg after :Heph).
|
||||
if cmdline:match("^%s*Heph%s+%S*$") then
|
||||
return vim.tbl_filter(function(n)
|
||||
return n:find(arglead, 1, true) == 1
|
||||
end, M.names())
|
||||
end
|
||||
return {}
|
||||
end
|
||||
|
||||
return M
|
||||
39
heph.nvim/lua/heph/config.lua
Normal file
39
heph.nvim/lua/heph/config.lua
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
--- Configuration defaults, socket resolution, and default keymaps.
|
||||
|
||||
local M = {}
|
||||
|
||||
M.defaults = {
|
||||
--- Path to hephd's unix socket. `nil` → resolved to the daemon default.
|
||||
socket = nil,
|
||||
--- Spawn a local hephd if the socket is not ready (off by default in v1).
|
||||
autostart = false,
|
||||
--- hephd binary for autostart.
|
||||
bin = "hephd",
|
||||
--- Set the default `<leader>h*` keymaps. `false` to opt out.
|
||||
keymaps = true,
|
||||
}
|
||||
|
||||
--- Resolve the socket path, mirroring hephd's `default_socket_path`:
|
||||
--- `$XDG_RUNTIME_DIR/heph/hephd.sock`, falling back to the temp dir.
|
||||
function M.resolve_socket(opt)
|
||||
if opt and #opt > 0 then
|
||||
return opt
|
||||
end
|
||||
local xdg = vim.env.XDG_RUNTIME_DIR
|
||||
local base = (xdg and #xdg > 0) and xdg or (vim.env.TMPDIR or "/tmp")
|
||||
return (base:gsub("/+$", "")) .. "/heph/hephd.sock"
|
||||
end
|
||||
|
||||
--- Apply the default keymaps (no-op when `opts.keymaps` is false).
|
||||
function M.apply_keymaps(opts)
|
||||
if not opts.keymaps then
|
||||
return
|
||||
end
|
||||
local map = vim.keymap.set
|
||||
map("n", "<leader>hj", function()
|
||||
require("heph.journal").open()
|
||||
end, { desc = "heph: today's journal" })
|
||||
-- Task/agenda maps are added with their views in slice 11b.
|
||||
end
|
||||
|
||||
return M
|
||||
58
heph.nvim/lua/heph/daemon.lua
Normal file
58
heph.nvim/lua/heph/daemon.lua
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
--- Locate, spawn, and wait on a `hephd` daemon. Shared by optional autostart
|
||||
--- and by the e2e harness (so test readiness uses the same definition the
|
||||
--- plugin does).
|
||||
|
||||
local uv = vim.uv or vim.loop
|
||||
|
||||
local M = {}
|
||||
|
||||
--- Spawn a `local`-mode hephd against `opts.db` listening on `opts.socket`.
|
||||
--- `opts.bin` defaults to `hephd` on PATH. Returns `{ handle, pid }`.
|
||||
function M.spawn(opts)
|
||||
local args = { "--mode", "local" }
|
||||
if opts.db then
|
||||
table.insert(args, "--db")
|
||||
table.insert(args, opts.db)
|
||||
end
|
||||
if opts.socket then
|
||||
table.insert(args, "--socket")
|
||||
table.insert(args, opts.socket)
|
||||
end
|
||||
local handle, pid = uv.spawn(opts.bin or "hephd", {
|
||||
args = args,
|
||||
stdio = { nil, nil, opts.stderr },
|
||||
}, function(code, signal)
|
||||
if opts.on_exit then
|
||||
opts.on_exit(code, signal)
|
||||
end
|
||||
end)
|
||||
if not handle then
|
||||
error("heph: failed to spawn hephd (bin=" .. (opts.bin or "hephd") .. ")")
|
||||
end
|
||||
return { handle = handle, pid = pid }
|
||||
end
|
||||
|
||||
--- Wait until `socket` both exists and accepts a real RPC (`health`). The
|
||||
--- existence check alone races the daemon's bind→accept, so we prove liveness
|
||||
--- with a round-trip on a throwaway session. Returns `true`, or `false, reason`.
|
||||
function M.wait_ready(socket, timeout)
|
||||
timeout = timeout or 5000
|
||||
if not vim.wait(timeout, function()
|
||||
return uv.fs_stat(socket) ~= nil
|
||||
end, 20) then
|
||||
return false, "socket never appeared: " .. socket
|
||||
end
|
||||
local session = require("heph.rpc").new_session(socket)
|
||||
local ok = vim.wait(timeout, function()
|
||||
return pcall(function()
|
||||
session:call("health", vim.empty_dict(), { timeout = 200 })
|
||||
end)
|
||||
end, 50)
|
||||
session:close()
|
||||
if not ok then
|
||||
return false, "socket present but not accepting rpc: " .. socket
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
return M
|
||||
33
heph.nvim/lua/heph/init.lua
Normal file
33
heph.nvim/lua/heph/init.lua
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
--- heph.nvim — the primary surface for hephaestus (tech-spec §8): an
|
||||
--- obsidian.nvim replacement that is a thin client of the local `hephd` over
|
||||
--- its unix-socket JSON-RPC. This module is the public entry point.
|
||||
|
||||
local config = require("heph.config")
|
||||
|
||||
local M = {}
|
||||
|
||||
--- The resolved config from the last `setup` (nil before setup).
|
||||
M.config = nil
|
||||
|
||||
--- Configure the plugin. `opts.socket` overrides the daemon socket path;
|
||||
--- `opts.keymaps = false` disables the default keymaps. Idempotent.
|
||||
function M.setup(opts)
|
||||
local cfg = vim.tbl_deep_extend("force", config.defaults, opts or {})
|
||||
cfg.socket = config.resolve_socket(cfg.socket)
|
||||
M.config = cfg
|
||||
|
||||
require("heph.rpc").setup(cfg.socket)
|
||||
|
||||
if cfg.autostart then
|
||||
local ok = require("heph.daemon").wait_ready(cfg.socket, 500)
|
||||
if not ok then
|
||||
require("heph.daemon").spawn({ bin = cfg.bin, socket = cfg.socket, db = nil })
|
||||
require("heph.daemon").wait_ready(cfg.socket, 5000)
|
||||
end
|
||||
end
|
||||
|
||||
config.apply_keymaps(cfg)
|
||||
return M
|
||||
end
|
||||
|
||||
return M
|
||||
19
heph.nvim/lua/heph/journal.lua
Normal file
19
heph.nvim/lua/heph/journal.lua
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
--- Daily journal (tech-spec §8). `journal.open_or_create` is idempotent (the
|
||||
--- node id is deterministic in (owner, date)), so opening today's note twice is
|
||||
--- safe.
|
||||
|
||||
local rpc = require("heph.rpc")
|
||||
local util = require("heph.util")
|
||||
|
||||
local M = {}
|
||||
|
||||
--- Open (creating if absent) the journal for `date` (default: today), in a
|
||||
--- buffer. Returns the node.
|
||||
function M.open(date)
|
||||
date = date or util.iso_today()
|
||||
local node = rpc.call("journal.open_or_create", { date = date })
|
||||
require("heph.node").open(node.id)
|
||||
return node
|
||||
end
|
||||
|
||||
return M
|
||||
62
heph.nvim/lua/heph/link.lua
Normal file
62
heph.nvim/lua/heph/link.lua
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
--- `[[wiki-link]]` parsing and following (tech-spec §8).
|
||||
---
|
||||
--- The cursor grammar mirrors `heph-core`'s `extract.rs`: a span `[[target]]`
|
||||
--- or `[[target|display]]`, where the resolvable name is everything left of the
|
||||
--- first `|`, trimmed. Resolution goes through `node.resolve` (exact, the same
|
||||
--- mapping that materializes stored `wiki` links) — never fuzzy `search`, which
|
||||
--- would mis-jump.
|
||||
|
||||
local rpc = require("heph.rpc")
|
||||
local util = require("heph.util")
|
||||
|
||||
local M = {}
|
||||
|
||||
--- The wiki target under the cursor on the current line, or nil. Scans for the
|
||||
--- `[[...]]` span that contains the cursor column.
|
||||
function M.target_under_cursor()
|
||||
local line = vim.api.nvim_get_current_line()
|
||||
local col = vim.api.nvim_win_get_cursor(0)[2] + 1 -- 1-based byte column
|
||||
local from = 1
|
||||
while true do
|
||||
local open_s, open_e = line:find("[[", from, true)
|
||||
if not open_s then
|
||||
return nil
|
||||
end
|
||||
local close_s, close_e = line:find("]]", open_e + 1, true)
|
||||
if not close_s then
|
||||
return nil
|
||||
end
|
||||
if col >= open_s and col <= close_e then
|
||||
local inner = line:sub(open_e + 1, close_s - 1)
|
||||
local target = inner:match("^([^|]*)") or ""
|
||||
target = target:gsub("^%s+", ""):gsub("%s+$", "")
|
||||
return (#target > 0) and target or nil
|
||||
end
|
||||
from = close_e + 1
|
||||
end
|
||||
end
|
||||
|
||||
--- Follow the `[[link]]` under the cursor to its node. Unresolved links are
|
||||
--- allowed (tech-spec §5) — an INFO toast, not an error.
|
||||
function M.follow()
|
||||
local target = M.target_under_cursor()
|
||||
if not target then
|
||||
util.notify("no [[link]] under cursor", vim.log.levels.WARN)
|
||||
return
|
||||
end
|
||||
local node = rpc.call("node.resolve", { title = target })
|
||||
if not node then
|
||||
util.notify("unresolved link [[" .. target .. "]]", vim.log.levels.INFO)
|
||||
return
|
||||
end
|
||||
require("heph.node").open(node.id)
|
||||
end
|
||||
|
||||
--- Attach the buffer-local `<CR>` follow keymap (only on heph:// buffers).
|
||||
function M.attach(buf)
|
||||
vim.keymap.set("n", "<CR>", function()
|
||||
M.follow()
|
||||
end, { buffer = buf, desc = "heph: follow [[link]]" })
|
||||
end
|
||||
|
||||
return M
|
||||
52
heph.nvim/lua/heph/node.lua
Normal file
52
heph.nvim/lua/heph/node.lua
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
--- Buffer-backed nodes (tech-spec §8): a node's markdown body is edited in a
|
||||
--- real buffer named `heph://node/<id>`. `:e` loads it via `node.get`; `:w`
|
||||
--- saves the whole buffer back via `node.update` (the backend CRDT-diffs the
|
||||
--- whole-buffer text, so sending the full body is correct and idempotent).
|
||||
|
||||
local rpc = require("heph.rpc")
|
||||
local util = require("heph.util")
|
||||
|
||||
local M = {}
|
||||
|
||||
--- `BufReadCmd` handler for `heph://node/<id>`: load the body into the buffer.
|
||||
function M.read(buf, uri)
|
||||
local _, id = util.parse_uri(uri)
|
||||
if not id then
|
||||
error("heph: not a node uri: " .. tostring(uri))
|
||||
end
|
||||
local node = rpc.call("node.get", { id = id })
|
||||
local body = (node and node.body) or ""
|
||||
-- `plain` split keeps a trailing "" element for a trailing newline, so the
|
||||
-- body round-trips exactly through `table.concat` on write.
|
||||
vim.api.nvim_buf_set_lines(buf, 0, -1, false, vim.split(body, "\n", { plain = true }))
|
||||
vim.b[buf].heph_node_id = id
|
||||
vim.b[buf].heph_node_kind = (node and node.kind) or "doc"
|
||||
vim.bo[buf].buftype = "acwrite" -- written via BufWriteCmd, not to a file
|
||||
vim.bo[buf].filetype = "markdown"
|
||||
vim.bo[buf].fileformat = "unix"
|
||||
vim.bo[buf].modified = false
|
||||
require("heph.link").attach(buf)
|
||||
end
|
||||
|
||||
--- `BufWriteCmd` handler: persist the whole buffer as the node body.
|
||||
function M.write(buf, _uri)
|
||||
local id = vim.b[buf].heph_node_id
|
||||
if not id then
|
||||
error("heph: buffer has no heph node id")
|
||||
end
|
||||
local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false)
|
||||
rpc.call("node.update", { id = id, body = table.concat(lines, "\n") })
|
||||
vim.bo[buf].modified = false
|
||||
end
|
||||
|
||||
--- Open (or focus) the buffer for node `id`.
|
||||
function M.open(id)
|
||||
vim.cmd.edit(util.node_uri(id))
|
||||
end
|
||||
|
||||
--- Force-reload the buffer for node `id` from the daemon (discards local edits).
|
||||
function M.reload(id)
|
||||
vim.cmd("edit! " .. vim.fn.fnameescape(util.node_uri(id)))
|
||||
end
|
||||
|
||||
return M
|
||||
202
heph.nvim/lua/heph/rpc.lua
Normal file
202
heph.nvim/lua/heph/rpc.lua
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
--- Line-delimited JSON-RPC client over hephd's unix socket (tech-spec §6).
|
||||
---
|
||||
--- The daemon speaks one JSON object per line: a request `{id, method, params}`
|
||||
--- gets exactly one response line `{id, result}` xor `{id, error}`. We talk to
|
||||
--- it over a libuv pipe and expose a **blocking** `call()` by pumping the event
|
||||
--- loop with `vim.wait` until the matching id returns — synchronous ergonomics
|
||||
--- over an async transport, which is what every surface call and the e2e tests
|
||||
--- want.
|
||||
---
|
||||
--- A `Session` is one connection; the module keeps a default singleton for the
|
||||
--- plugin and lets tests open isolated sessions (`new_session`) so an assertion
|
||||
--- never shares state with the buffer under test.
|
||||
|
||||
local uv = vim.uv or vim.loop
|
||||
|
||||
local Session = {}
|
||||
Session.__index = Session
|
||||
|
||||
--- Create an unconnected session bound to `socket_path` (lazy connect).
|
||||
function Session.new(socket_path)
|
||||
return setmetatable({
|
||||
socket_path = socket_path,
|
||||
pipe = nil,
|
||||
buf = "", -- partial-line accumulator
|
||||
pending = {}, -- [id] = { done, result, err }
|
||||
next_id = 0,
|
||||
connected = false,
|
||||
}, Session)
|
||||
end
|
||||
|
||||
--- Drain complete `\n`-terminated lines out of the read buffer. Runs in the
|
||||
--- libuv fast-event context: string/table ops only, never `vim.api`/`vim.fn`.
|
||||
function Session:_on_bytes(chunk)
|
||||
self.buf = self.buf .. chunk
|
||||
while true do
|
||||
local nl = self.buf:find("\n", 1, true)
|
||||
if not nl then
|
||||
break
|
||||
end
|
||||
local line = self.buf:sub(1, nl - 1)
|
||||
self.buf = self.buf:sub(nl + 1)
|
||||
if #line > 0 then
|
||||
self:_dispatch(line)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
--- Match one response line to its pending call by id. A line with no id is a
|
||||
--- server notification (tech-spec §6, slice 11d) — ignored for now.
|
||||
function Session:_dispatch(line)
|
||||
-- `luanil` decodes JSON null to Lua nil (not the vim.NIL sentinel), so a
|
||||
-- `null` result / nullable field reads as a plain absent value.
|
||||
local ok, msg = pcall(vim.json.decode, line, { luanil = { object = true, array = true } })
|
||||
if not ok or type(msg) ~= "table" or msg.id == nil then
|
||||
return
|
||||
end
|
||||
local slot = self.pending[msg.id]
|
||||
if not slot then
|
||||
return
|
||||
end
|
||||
if msg.error ~= nil then
|
||||
slot.err = string.format("rpc error %s: %s", tostring(msg.error.code), tostring(msg.error.message))
|
||||
else
|
||||
slot.result = msg.result
|
||||
end
|
||||
slot.done = true
|
||||
end
|
||||
|
||||
--- Fail every outstanding call so blocked `vim.wait`s unblock immediately
|
||||
--- rather than each waiting out its full timeout. Safe to call from the read
|
||||
--- callback (fast-event context): only touches tables and `vim.schedule`.
|
||||
function Session:_fail_all(reason)
|
||||
self.connected = false
|
||||
for _, slot in pairs(self.pending) do
|
||||
if not slot.done then
|
||||
slot.err = reason
|
||||
slot.done = true
|
||||
end
|
||||
end
|
||||
local pipe = self.pipe
|
||||
self.pipe = nil
|
||||
if pipe then
|
||||
vim.schedule(function()
|
||||
pcall(function()
|
||||
pipe:close()
|
||||
end)
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
--- Connect (idempotent). Blocks until the connect callback fires.
|
||||
function Session:_ensure()
|
||||
if self.connected then
|
||||
return
|
||||
end
|
||||
assert(self.socket_path, "heph: no socket configured (call require('heph').setup{ socket = ... })")
|
||||
local pipe = uv.new_pipe(false)
|
||||
local done, cerr = false, nil
|
||||
pipe:connect(self.socket_path, function(e)
|
||||
cerr = e
|
||||
done = true
|
||||
end)
|
||||
if not vim.wait(5000, function()
|
||||
return done
|
||||
end, 10) then
|
||||
pcall(function()
|
||||
pipe:close()
|
||||
end)
|
||||
error("heph: timed out connecting to hephd at " .. self.socket_path)
|
||||
end
|
||||
if cerr then
|
||||
pcall(function()
|
||||
pipe:close()
|
||||
end)
|
||||
error("heph: cannot connect to hephd at " .. self.socket_path .. ": " .. cerr)
|
||||
end
|
||||
self.pipe = pipe
|
||||
self.buf = ""
|
||||
self.connected = true
|
||||
pipe:read_start(function(rerr, chunk)
|
||||
if rerr then
|
||||
self:_fail_all("connection error: " .. rerr)
|
||||
elseif chunk == nil then
|
||||
self:_fail_all("hephd closed the connection")
|
||||
else
|
||||
self:_on_bytes(chunk)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
--- Call `method` with `params`, blocking until the response. Raises a Lua error
|
||||
--- on an rpc error or timeout. `opts.timeout` defaults to 5000ms.
|
||||
function Session:call(method, params, opts)
|
||||
opts = opts or {}
|
||||
self:_ensure()
|
||||
self.next_id = self.next_id + 1
|
||||
local id = self.next_id
|
||||
local slot = { done = false }
|
||||
self.pending[id] = slot
|
||||
|
||||
-- Empty params must serialize as `{}`, not `[]` (the daemon parses an object).
|
||||
if params == nil or (type(params) == "table" and vim.tbl_isempty(params)) then
|
||||
params = vim.empty_dict()
|
||||
end
|
||||
local line = vim.json.encode({ id = id, method = method, params = params }) .. "\n"
|
||||
self.pipe:write(line)
|
||||
|
||||
local ok = vim.wait(opts.timeout or 5000, function()
|
||||
return slot.done
|
||||
end, 5)
|
||||
self.pending[id] = nil
|
||||
if not ok then
|
||||
error("heph: rpc timeout calling " .. method)
|
||||
end
|
||||
if slot.err then
|
||||
error("heph: " .. slot.err)
|
||||
end
|
||||
return slot.result
|
||||
end
|
||||
|
||||
--- Close the connection, failing any in-flight calls.
|
||||
function Session:close()
|
||||
self:_fail_all("connection closed")
|
||||
end
|
||||
|
||||
local M = { Session = Session }
|
||||
|
||||
--- (Re)bind the default singleton session to `socket_path`.
|
||||
function M.setup(socket_path)
|
||||
if M._default then
|
||||
M._default:close()
|
||||
end
|
||||
M._default = Session.new(socket_path)
|
||||
return M._default
|
||||
end
|
||||
|
||||
--- The default singleton session (created unconnected if absent).
|
||||
function M.session()
|
||||
if not M._default then
|
||||
M._default = Session.new(nil)
|
||||
end
|
||||
return M._default
|
||||
end
|
||||
|
||||
--- Blocking call on the default session.
|
||||
function M.call(method, params, opts)
|
||||
return M.session():call(method, params, opts)
|
||||
end
|
||||
|
||||
--- An isolated session for a socket — used by tests for independent assertions.
|
||||
function M.new_session(socket_path)
|
||||
return Session.new(socket_path)
|
||||
end
|
||||
|
||||
--- Close the default session.
|
||||
function M.close()
|
||||
if M._default then
|
||||
M._default:close()
|
||||
end
|
||||
end
|
||||
|
||||
return M
|
||||
26
heph.nvim/lua/heph/util.lua
Normal file
26
heph.nvim/lua/heph/util.lua
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
--- Small shared helpers: URIs, dates, notifications.
|
||||
|
||||
local M = {}
|
||||
|
||||
--- Today's date as an ISO `YYYY-MM-DD`. Uses the real wall clock — the plugin
|
||||
--- picks "today"; `heph-core` stays clock-injected, this is surface-only.
|
||||
function M.iso_today()
|
||||
return os.date("%Y-%m-%d")
|
||||
end
|
||||
|
||||
--- The buffer URI for a node id.
|
||||
function M.node_uri(id)
|
||||
return "heph://node/" .. id
|
||||
end
|
||||
|
||||
--- Parse a `heph://<kind>/<id>` URI into `kind, id` (nil on no match).
|
||||
function M.parse_uri(uri)
|
||||
return uri:match("^heph://([^/]+)/(.+)$")
|
||||
end
|
||||
|
||||
--- Notify with a consistent `heph:` prefix.
|
||||
function M.notify(msg, level)
|
||||
vim.notify("heph: " .. msg, level or vim.log.levels.INFO)
|
||||
end
|
||||
|
||||
return M
|
||||
Loading…
Add table
Add a link
Reference in a new issue