Phase 1: v1 prototype #1

Merged
eblume merged 91 commits from feature/v1-prototype into main 2026-06-03 20:48:23 -07:00
29 changed files with 1240 additions and 10 deletions
Showing only changes of commit ee865e5635 - Show all commits

heph.nvim: RPC client + buffer editing + wiki-links + journal (slice 11a)
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>
Erich Blume 2026-06-01 20:33:29 -07:00

View file

@ -24,8 +24,8 @@ See **[docs/explanation/design.md](docs/explanation/design.md)** for the vision
| `client` mode + `RemoteStore` (online-only, no replica) | ✅ done |
| OIDC hub auth — bearer-token verification + owner gate | ✅ done |
| OIDC client — device-code login, keyring token cache | ✅ done |
| `heph.nvim` (primary surface) | ⏳ next |
| `heph.nvim` (primary surface) | ⏳ |
| `heph.nvim` (primary surface) — RPC client, buffer-backed editing, wiki-link follow, journal (slice 11a) | ✅ done |
| `heph.nvim` — task/agenda views, promotion, CI runner (slices 11b11c) | ⏳ next |
## Architecture

View file

@ -123,7 +123,7 @@ pub(super) fn sync_wiki_links(
let mut desired: Vec<String> = Vec::new();
let mut desired_set: HashSet<String> = HashSet::new();
for target in extract(body).wiki_links {
if let Some(dst) = resolve(conn, owner, &target)? {
if let Some(dst) = resolve_id(conn, owner, &target)? {
if dst != src_id && desired_set.insert(dst.clone()) {
desired.push(dst);
}
@ -159,8 +159,9 @@ pub(super) fn sync_wiki_links(
}
/// Resolve a wiki-link target to a node id for this owner, matching an alias
/// first, then an exact title. `None` if nothing matches.
fn resolve(conn: &Connection, owner: &str, target: &str) -> Result<Option<String>> {
/// first, then an exact title. `None` if nothing matches. Shared by `wiki`
/// link materialization and the `node.resolve` surface (tech-spec §5, §6).
pub(super) fn resolve_id(conn: &Connection, owner: &str, target: &str) -> Result<Option<String>> {
let by_alias: Option<String> = conn
.query_row(
"SELECT n.id FROM aliases a JOIN nodes n ON n.id = a.node_id

View file

@ -200,6 +200,13 @@ impl Store for LocalStore {
nodes::tombstone(&self.conn, &self.owner_id, now, id)
}
fn resolve_node(&self, title: &str) -> Result<Option<Node>> {
match links::resolve_id(&self.conn, &self.owner_id, title)? {
Some(id) => nodes::get(&self.conn, &id),
None => Ok(None),
}
}
fn create_task(&mut self, input: NewTask) -> Result<Task> {
let now = self.clock.now_ms();
tasks::create(&mut self.conn, &self.owner_id, now, input)
@ -381,6 +388,28 @@ mod tests {
assert_eq!(v, latest_version());
}
#[test]
fn resolve_node_matches_exact_title_not_fuzzy() {
use crate::model::NewNode;
let mut store = store_at(1);
let roof = store.create_node(NewNode::doc("Roof", "shingles")).unwrap();
// A fuzzy/FTS match would surface this for "Roof"; exact resolve must not.
store
.create_node(NewNode::doc("Roofing options", "estimates"))
.unwrap();
let got = store.resolve_node("Roof").unwrap().expect("exact title");
assert_eq!(got.id, roof.id);
// A prefix is not an exact title — resolves to nothing, never the
// fuzzy neighbour.
assert!(store.resolve_node("Roo").unwrap().is_none());
// Tombstoned nodes are excluded.
store.tombstone_node(&roof.id).unwrap();
assert!(store.resolve_node("Roof").unwrap().is_none());
}
#[test]
fn opening_twice_is_idempotent_for_the_local_user() {
let conn = Connection::open_in_memory().unwrap();

View file

@ -40,6 +40,15 @@ pub trait Store {
/// Tombstone (soft-delete) a node. No hard deletes (tech-spec §4.3).
fn tombstone_node(&mut self, id: &str) -> Result<()>;
/// Resolve a wiki-link target (`[[title]]`) to a node, **exactly** — an
/// alias match first, then an exact, owner-scoped, non-tombstoned title
/// match; `None` if nothing matches (an unresolved link is allowed, §5).
///
/// This is the same mapping the store uses to materialize `wiki` links, so
/// a surface's "follow link under cursor" jumps to the *same* node the
/// stored link points at — unlike fuzzy `search` (tech-spec §6, §8).
fn resolve_node(&self, title: &str) -> Result<Option<Node>>;
// --- tasks ---
/// Create a committed task, auto-creating its canonical context `doc` and

View file

@ -133,6 +133,10 @@ impl Store for RemoteStore {
self.call("node.tombstone", json!({ "id": id })).map(|_| ())
}
fn resolve_node(&self, title: &str) -> Result<Option<Node>> {
self.call_as("node.resolve", json!({ "title": title }))
}
fn create_task(&mut self, input: NewTask) -> Result<Task> {
self.call_as("task.create", json!(input))
}

View file

@ -109,6 +109,11 @@ struct IdParam {
id: String,
}
#[derive(Deserialize)]
struct ResolveParams {
title: String,
}
#[derive(Deserialize)]
struct UpdateParams {
id: String,
@ -226,6 +231,10 @@ pub fn dispatch(store: &mut dyn Store, method: &str, params: Value) -> Result<Va
store.tombstone_node(&p.id)?;
json!({ "ok": true })
}
"node.resolve" => {
let p: ResolveParams = parse(params)?;
json!(store.resolve_node(&p.title)?)
}
"task.create" => {
let p: NewTask = parse(params)?;
json!(store.create_task(p)?)

View file

@ -75,6 +75,33 @@ fn node_create_and_get_round_trip_over_socket() {
assert_eq!(missing, Value::Null);
}
#[test]
fn node_resolve_is_exact_not_fuzzy_over_socket() {
let (socket, _dir) = spawn_daemon();
let mut c = client(&socket);
let target = c
.call("node.create", json!({ "kind": "doc", "title": "Roof" }))
.unwrap();
let target_id = target["id"].as_str().unwrap().to_string();
// A fuzzy neighbour that an FTS `search` for "Roof" would also surface.
c.call(
"node.create",
json!({ "kind": "doc", "title": "Roofing options" }),
)
.unwrap();
// Exact title resolves to exactly the target node.
let got = c.call("node.resolve", json!({ "title": "Roof" })).unwrap();
assert_eq!(got["id"], target_id);
// An unresolved link is JSON null, not an error (tech-spec §5).
let missing = c
.call("node.resolve", json!({ "title": "Nonexistent" }))
.unwrap();
assert_eq!(missing, Value::Null);
}
#[test]
fn task_create_appears_in_next_with_context_link() {
let (socket, _dir) = spawn_daemon();

View file

@ -14,3 +14,4 @@ Begin the v1 prototype (Phase 1, tech-spec §11.1), built in TDD slices:
- Hub authentication (§13, slice 10a): the sync hub now verifies an OIDC bearer token on `/sync/*` and `/rpc` — RS256-pinned JWT validation with exact issuer/audience, expiry, and a required subject; JWKS discovered and cached, refetched on key rotation (`jsonwebtoken`). Enabled with `hephd --mode server --oidc-issuer <url> --oidc-audience <client-id>` (open when unset, for local dev). A single-tenant owner gate binds the hub to the first authenticated identity and rejects any other. Verification sits behind a `TokenVerifier` trait, so it's tested entirely offline (stub middleware + an adversarial battery against an in-process mock IdP).
- Client authentication (§13, slice 10b): `heph auth login --hub-url <url> --issuer <url> --client-id <id>` runs the OAuth 2.0 device-code flow and caches the token in the OS keyring; spokes and `client` mode attach it to hub requests, refreshing on expiry (`--oidc-issuer`/`--oidc-client-id`). Offline-tested against a mock OAuth server and a full spoke-to-authenticated-hub loop. (Auth/proxy HTTP uses the runtime-free `ureq`, since `reqwest::blocking` is unsafe inside the async daemon.)
- CI runs the Rust suite (fmt/clippy/test) via the project build hook.
- `heph.nvim` slice 11a (§8) — the primary surface begins: a Neovim plugin that is a thin client of the local `hephd` over its unix socket. A `vim.uv` JSON-RPC client (blocking `call` via `vim.wait`, id-demuxed, partial-line buffered, JSON `null`→Lua `nil`); buffer-backed nodes (`heph://node/<id>` with `BufReadCmd``node.get` / `BufWriteCmd``node.update`, whole-buffer body round-tripping exactly through the CRDT); `[[wiki-link]]` follow on `<CR>` via a new exact `node.resolve {title}` RPC (alias-then-title, the same mapping that materializes `wiki` links — unresolved links allowed); the daily journal (`:Heph today`); and the `:Heph` command surface. Headless e2e (§9) drives the plugin against a real daemon over a temp socket with a self-contained busted-style runner (no external plugins, no network): journal round-trip, follow-link, and link-two-docs/backlink.

View file

@ -0,0 +1,67 @@
---
title: heph.nvim
modified: 2026-06-01
tags:
- reference
- design
---
# heph.nvim
The primary user surface (tech-spec §8): a Neovim plugin that replaces
obsidian.nvim and is a **thin client of the local `hephd`** over its
unix-socket JSON-RPC. Notes, journals, and tasks are edited as ordinary
buffers; the daemon owns all storage and sync. Built in checkpointed slices on
`feature/v1-prototype`; this card tracks the stable surface as it lands.
## Architecture
`heph.nvim/lua/heph/` modules, each small and single-purpose:
| Module | Responsibility |
|---|---|
| `rpc` | libuv (`vim.uv`) unix-socket JSON-RPC client. A blocking `call()` is built over the async pipe by pumping the loop with `vim.wait` until the matching id returns. Demuxes responses by id; partial lines are buffered; JSON `null` decodes to Lua `nil` (`luanil`). A `Session` is one connection — the module keeps a default singleton and lets tests open isolated sessions. |
| `node` | Buffer-backed nodes. A node is a buffer named `heph://node/<id>` with `buftype=acwrite`; `BufReadCmd` loads the body via `node.get`, `BufWriteCmd` saves the whole buffer via `node.update`. |
| `link` | Parse the `[[wiki-link]]` under the cursor (mirroring `extract.rs` grammar) and follow it via `node.resolve` (exact, never fuzzy `search`). Unresolved links are allowed. |
| `journal` | Open/create a dated journal node (idempotent — deterministic id). |
| `daemon` | Locate / spawn / readiness-poll `hephd` (shared with the e2e harness). |
| `config` / `init` | `setup(opts)`, socket resolution, default keymaps. |
| `command` | The `:Heph <subcommand>` dispatch + completion. |
Surfaces never touch SQLite — every operation is a daemon RPC (tech-spec §3).
The plugin is **mode-agnostic**: Tactical/Strategic/Organizational are
plugin-side compositions of daemon primitives, not daemon concepts.
## Daemon RPC dependencies
Beyond the existing methods (tech-spec §6), the plugin relies on
**`node.resolve {title} → Node | null`**: an exact, owner-scoped,
non-tombstoned alias-then-title match — the same mapping the store uses to
materialize `wiki` links, so "follow link under cursor" jumps to the *same*
node the stored link points at.
## Commands (as of slice 11a)
| Command | Action |
|---|---|
| `:Heph today` | Open today's journal |
| `:Heph journal <YYYY-MM-DD>` | Open a dated journal |
| `:Heph follow` (also `<CR>` in a node buffer) | Follow the `[[link]]` under the cursor |
| `:Heph open <id>` | Open a node buffer by id |
Task/agenda views (`:Heph next`/`list`/`capture`, set-attention, done/drop),
the per-task log, and context-item **promotion** arrive in slices 11b/11c.
## Testing (tech-spec §9)
The headless e2e suite drives the plugin in `nvim --headless` against a real
`hephd` over a temp socket, asserting both buffer contents and resulting DB
state (via an isolated RPC session). It uses a **self-contained busted-style
runner** (`tests/e2e/runner.lua`) — no external plugins, no network — so CI is
deterministic. `make test` builds the daemon and runs it; a deliberately
failing spec exits non-zero (no false-green).
## Related
- [[tech-spec]] — §8 surface spec, §6 RPC API, §9 testing strategy
- [[design]] — the mode model (Tactical/Strategic/Organizational) and rationale

View file

@ -13,6 +13,7 @@ Technical reference material for the repository tooling that ships with this pro
## Project
- [[tech-spec]] — Hephaestus technical specification (data model, RPC API, "what is next?" ranking, recurrence, testing strategy, v1 scope)
- [[heph-nvim]] — The Neovim plugin surface: architecture, buffer-backed editing, RPC dependencies, commands, and the headless e2e harness
## Template Surface Area

View file

@ -327,7 +327,7 @@ See [[design]] §5§7 for the constraints later phases impose on present choi
## 14. Implementation status (Phase 1 tracker)
> Cross-session resume tracker for the Phase 1 C1 (branch `feature/v1-prototype`, PR #1). Updated 2026-06-01 — **112 tests green** (`cargo test --all`), `clippy -D warnings` + `fmt` + `prek` clean. Workspace: `crates/heph-core`, `crates/hephd`, `crates/heph` (no `heph.nvim/` yet).
> Cross-session resume tracker for the Phase 1 C1 (branch `feature/v1-prototype`, PR #1). Updated 2026-06-01 — **114 Rust tests green** (`cargo test --all`) + the heph.nvim headless e2e suite (`make -C heph.nvim test`), `clippy -D warnings` + `fmt` + `prek` clean. Workspace: `crates/heph-core`, `crates/hephd`, `crates/heph`, plus `heph.nvim/` (slice 11a).
**Done**
@ -345,14 +345,17 @@ See [[design]] §5§7 for the constraints later phases impose on present choi
- ✅ **Auth — client side (§13, slice 10b):** OAuth2 **device-code flow** (`hephd::oauth::DeviceFlow`: discover → start → poll handling `authorization_pending`/`slow_down`, + refresh). `TokenStore` (OS keyring via `keyring`, in-memory for tests); `current_bearer` refreshes on expiry. `heph auth login` runs the flow + caches the token; spokes (`sync_once`) and `client` mode (`RemoteStore`) attach the bearer, refreshing as needed (`--oidc-issuer`/`--oidc-client-id`). **All auth/proxy HTTP uses `ureq`** (runtime-free blocking) — `reqwest::blocking` panics inside the daemon's `spawn_blocking`; async `reqwest` remains only for `sync_once`. Tested offline against a mock OAuth server (device flow, refresh, store) + a full spoke⇄authed-hub loop.
- ✅ **CLI (§1):** `heph` next/task/doc/get/export/search/journal/**auth login·logout**.
- ✅ **CI (§9):** `.forgejo/scripts/build` runs fmt/clippy/test (self-bootstrapping rustup).
- ✅ **`heph.nvim` slice 11a (§8) — the primary surface begins:** the Lua plugin (`heph.nvim/`) as a thin client of the `hephd` unix socket. **RPC client** over a `vim.uv` pipe (blocking `call` via `vim.wait`; id-demuxed; partial-line buffered; `luanil` so JSON `null``nil`; isolated `Session`s for tests). **Buffer-backed nodes**`heph://node/<id>` buffers (`buftype=acwrite`), `BufReadCmd``node.get` / `BufWriteCmd``node.update` (whole-buffer body, CRDT-diffed; exact round-trip). **`[[wiki-link]]` follow** on `<CR>` via a new **`node.resolve {title}`** RPC (exact alias-then-title match, the same mapping that materializes `wiki` links — never fuzzy `search`; unresolved links allowed). **Daily journal** (`:Heph today`/`journal <date>`, idempotent). `:Heph` command surface + completion. **Headless e2e (§9):** drives the plugin in `nvim --headless` against a real daemon over a temp socket via a **self-contained busted-style runner** (`tests/e2e/runner.lua` — no external plugins/network, deterministic CI exit codes); specs cover journal round-trip, follow-link (+ unresolved no-op), and link-two-docs/backlink. `make -C heph.nvim test` builds the daemon and runs it.
**Not yet done (resume order)**
> The Rust backend is feature-complete; `heph.nvim` is the one remaining build slice. The rest are non-blocking polish + an end-of-v1 sweep (§11).
> The Rust backend is feature-complete; `heph.nvim` is being built in checkpointed sub-slices (11a done). The rest are non-blocking polish + an end-of-v1 sweep (§11).
1. ⏳ **`heph.nvim` (§8) — the next slice, the primary surface:** obsidian.nvim parity + task/agenda views over the `hephd` unix socket; headless-nvim e2e (needs `neovim` + `plenary.nvim` on the CI runner). First non-Rust slice — likely wants its own short design pass (Lua layout, RPC client, CI runner setup) before coding.
2. ⏳ **Adoption refinement + multi-tenant (§13) — non-blocking:** local→authed **adoption** currently rewrites `owner_id` (`adopt_owner`) but not yet the owner-embedded deterministic ids (journal/tag) + their links; and the hub is single-tenant (one owner per store) — owner-per-token storage is a future extension.
3. ⏳ **Dependency-refresh pass (§11) — before declaring v1 done:** sweep all external deps to latest stable (e.g. `keyring` 3→4, which restructured to `keyring_core`), re-run the full suite.
1. ⏳ **`heph.nvim` slice 11b (§8) — task views:** enrich `list` to titled rows; Tactical `next` + Organizational `list` views; task capture, set-attention, mark done/dropped; per-task log quick-append; `vim.ui.select` pickers (Telescope auto-upgrade when present). e2e: capture→next→context→checklist→done, and the recurring fresh-checklist workflow.
2. ⏳ **`heph.nvim` slice 11c (§8) — promotion + CI runner:** add **`task.promote`** (mint a committed task from a `- [ ]` context-item line, rewrite it into a `[[link]]`; `item_ref` = 1-based code-fence-aware context-item index) + the in-buffer promote flow + its e2e; extend `.forgejo/scripts/build` to build `hephd` and run the nvim e2e suite (runner needs `neovim`; the self-contained busted runner needs **no** plenary).
3. ⏳ **`heph.nvim` slice 11d (§6/§8) — DEFERRED, post-parity:** daemon **server-push** notification framing (no-`id` lines on the socket; the client read-loop already demuxes them) + the **dirty-buffer reconcile** (the §8 "known-hard" case) + the "update-arrives-while-open" e2e (§9).
4. ⏳ **Adoption refinement + multi-tenant (§13) — non-blocking:** local→authed **adoption** currently rewrites `owner_id` (`adopt_owner`) but not yet the owner-embedded deterministic ids (journal/tag) + their links; and the hub is single-tenant (one owner per store) — owner-per-token storage is a future extension.
5. ⏳ **Dependency-refresh pass (§11) — before declaring v1 done:** sweep all external deps to latest stable (e.g. `keyring` 3→4, which restructured to `keyring_core`), re-run the full suite.
## Related

16
heph.nvim/Makefile Normal file
View file

@ -0,0 +1,16 @@
# heph.nvim — headless e2e suite (tech-spec §9).
#
# `make test` builds the daemon, then drives the plugin in headless Neovim
# against a real hephd over a temp socket, via a self-contained busted-style
# runner (no external plugins, no network — see tests/e2e/runner.lua).
HEPHD_BIN ?= $(CURDIR)/../target/debug/hephd
export HEPHD_BIN
.PHONY: test build-hephd
build-hephd:
cargo build -p hephd --manifest-path $(CURDIR)/../Cargo.toml
test: build-hephd
nvim --headless -u NONE -c "luafile tests/e2e/run.lua"

55
heph.nvim/README.md Normal file
View file

@ -0,0 +1,55 @@
# heph.nvim
The primary surface for [hephaestus](../README.md) — an obsidian.nvim
replacement that is a thin client of the local `hephd` daemon over its
unix-socket JSON-RPC (tech-spec §8). Notes, journals, and tasks are edited as
ordinary Neovim buffers; saving routes through the daemon.
> **Status:** built in checkpointed slices. **11a (this slice)** delivers the
> RPC client, buffer-backed editing, `[[wiki-link]]` following, and the daily
> journal. Task/agenda views (`:Heph next`/`list`/capture), the per-task log,
> and promotion arrive in 11b/11c. See tech-spec §14.
## How it works
- **Buffer-backed nodes.** A node is edited in a buffer named
`heph://node/<id>`. Opening it loads the markdown body via `node.get`; `:w`
saves the whole buffer back via `node.update` (the backend diffs it into a
text CRDT, so sending the full buffer is correct). `buftype=acwrite`.
- **Links.** Press `<CR>` on a `[[wiki-link]]` to jump to its node (resolved
exactly via `node.resolve`). Unresolved links are allowed — they just notify.
- **Journal.** `:Heph today` (or `:Heph journal YYYY-MM-DD`) opens a dated
journal note; the id is deterministic so reopening is idempotent.
## Setup
Requires a running `hephd` (`hephd --mode local`) and Neovim ≥ 0.10.
```lua
require("heph").setup({
-- socket = "/run/user/1000/heph/hephd.sock", -- defaults to hephd's path
-- keymaps = true, -- <leader>h* maps
-- autostart = false, -- spawn hephd if absent
})
```
## Commands
| Command | Action |
|---|---|
| `:Heph today` | Open today's journal |
| `:Heph journal <YYYY-MM-DD>` | Open a dated journal |
| `:Heph follow` | Follow the `[[link]]` under the cursor (also `<CR>`) |
| `:Heph open <id>` | Open a node buffer by id |
## Tests
The e2e suite drives the plugin in headless Neovim against a real daemon:
```bash
make test # builds hephd, runs the headless e2e suite
```
The suite uses a small self-contained busted-style runner
(`tests/e2e/runner.lua`) — no external plugins and no network, so it is
deterministic in CI. It needs only Neovim (≥ 0.10) and a built `hephd`.

View 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

View 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

View 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

View 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

View 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

View 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

View 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
View 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

View 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

52
heph.nvim/plugin/heph.lua Normal file
View file

@ -0,0 +1,52 @@
--- heph.nvim plugin entry: register the `heph://` buffer autocmds and the
--- `:Heph` command. Loaded once by Neovim from `runtimepath/plugin/`.
if vim.g.loaded_heph then
return
end
vim.g.loaded_heph = true
local grp = vim.api.nvim_create_augroup("heph", { clear = true })
-- `heph://node/<id>` buffers load and save through the daemon (tech-spec §8).
vim.api.nvim_create_autocmd("BufReadCmd", {
group = grp,
pattern = "heph://*",
callback = function(ev)
local ok, err = pcall(require("heph.node").read, ev.buf, ev.match)
if not ok then
vim.notify(tostring(err), vim.log.levels.ERROR)
end
end,
})
vim.api.nvim_create_autocmd("BufWriteCmd", {
group = grp,
pattern = "heph://*",
callback = function(ev)
local ok, err = pcall(require("heph.node").write, ev.buf, ev.match)
if not ok then
vim.notify(tostring(err), vim.log.levels.ERROR)
end
end,
})
-- Release the socket cleanly on exit.
vim.api.nvim_create_autocmd("VimLeavePre", {
group = grp,
callback = function()
pcall(function()
require("heph.rpc").close()
end)
end,
})
vim.api.nvim_create_user_command("Heph", function(opts)
require("heph.command").run(opts)
end, {
nargs = "*",
desc = "hephaestus",
complete = function(arglead, cmdline, cursorpos)
return require("heph.command").complete(arglead, cmdline, cursorpos)
end,
})

View file

@ -0,0 +1,32 @@
-- Workflow (d): link two documents — type [[B]] in A, save, assert the
-- backlink B<-A was materialized by the daemon's extraction.
local h = require("e2e.helpers")
describe("link two docs", function()
local ctx
before_each(function()
ctx = h.start()
end)
after_each(function()
h.stop(ctx)
end)
it("typing [[B]] in A and saving creates a wiki backlink B<-A", function()
local b = h.create_doc("B", "the B doc")
local a = h.create_doc("A", "")
local buf = h.open(a.id)
h.set_lines(buf, { "see [[B]]" })
h.save(buf)
local backlinks = ctx.q:call("links.backlinks", { id = b.id })
local found = false
for _, l in ipairs(backlinks) do
if l.src_id == a.id and l.link_type == "wiki" then
found = true
end
end
assert.is_true(found, "expected a wiki backlink from A to B")
end)
end)

View file

@ -0,0 +1,40 @@
-- Workflow (c): follow a [[link]] under the cursor on <CR> to the target doc.
local h = require("e2e.helpers")
describe("follow link", function()
local ctx
before_each(function()
ctx = h.start()
end)
after_each(function()
h.stop(ctx)
end)
it("follows [[B]] under the cursor to doc B", function()
local b = h.create_doc("B", "the B doc")
local a = h.create_doc("A", "see [[B]] here")
local buf = h.open(a.id)
local line = vim.api.nvim_buf_get_lines(buf, 0, 1, false)[1]
local open_at = line:find("%[%[B") -- start of "[[B"
assert.is_truthy(open_at)
-- Put the cursor on the target inside the brackets.
vim.api.nvim_win_set_cursor(0, { 1, open_at + 1 })
require("heph.link").follow()
local cur = vim.api.nvim_get_current_buf()
assert.are.equal("heph://node/" .. b.id, vim.api.nvim_buf_get_name(cur))
assert.are.equal(b.id, vim.b[cur].heph_node_id)
end)
it("leaves an unresolved [[link]] in place without erroring", function()
local a = h.create_doc("Lonely", "points to [[Nowhere]]")
local buf = h.open(a.id)
vim.api.nvim_win_set_cursor(0, { 1, 12 }) -- inside [[Nowhere]]
require("heph.link").follow()
-- Still on the same buffer; no jump happened.
assert.are.equal(buf, vim.api.nvim_get_current_buf())
end)
end)

View file

@ -0,0 +1,137 @@
--- E2e harness (tech-spec §9): spin up a real `hephd` in local mode against a
--- temp DB + socket, point the plugin at it, and tear it down deterministically.
--- Step builders (create doc/task, open, edit, save) are reusable across specs.
local rpc = require("heph.rpc")
local daemon = require("heph.daemon")
local M = {}
local counter = 0
local function repo_root()
-- ":p" makes this absolute regardless of how the runner was launched.
local here = vim.fn.fnamemodify(debug.getinfo(1, "S").source:sub(2), ":p")
return vim.fn.fnamemodify(here, ":h:h:h:h") -- .../heph.nvim/tests/e2e -> repo root
end
--- The hephd binary to drive: `$HEPHD_BIN` or the workspace debug build.
function M.hephd_bin()
local env = vim.env.HEPHD_BIN
if env and #env > 0 then
return env
end
return repo_root() .. "/target/debug/hephd"
end
-- A short unique temp dir. unix socket paths are capped near 104 bytes
-- (`sun_path`), so we stay under a short base, never `tempname()`.
local function unique_dir()
counter = counter + 1
local base = vim.env.HEPH_TEST_TMP
base = (base and #base > 0) and base:gsub("/+$", "") or "/tmp"
local dir = string.format("%s/h%d-%d", base, vim.fn.getpid(), counter)
vim.fn.mkdir(dir, "p")
return dir
end
--- Start a fresh daemon and bind the plugin's rpc to it. Returns a `ctx` with:
--- `dir, sock, db, daemon, exited, q` (an isolated session for assertions).
function M.start()
local dir = unique_dir()
local sock = dir .. "/s"
local db = dir .. "/db"
assert(#sock < 104, "socket path too long for sun_path: " .. sock)
local bin = M.hephd_bin()
assert(
vim.fn.executable(bin) == 1,
"hephd not built/executable: " .. bin .. " (run: cargo build -p hephd)"
)
local exited = { done = false }
local d = daemon.spawn({
bin = bin,
db = db,
socket = sock,
on_exit = function()
exited.done = true
end,
})
local ok, reason = daemon.wait_ready(sock, 5000)
assert(ok, "daemon not ready: " .. tostring(reason))
rpc.setup(sock) -- the plugin's default session, used by buffers/commands
return {
dir = dir,
sock = sock,
db = db,
daemon = d,
exited = exited,
q = rpc.new_session(sock), -- isolated session for independent assertions
}
end
--- Tear down: close sessions, delete heph:// buffers, reap the daemon, rm temp.
function M.stop(ctx)
if not ctx then
return
end
pcall(function()
ctx.q:close()
end)
pcall(function()
rpc.close()
end)
for _, b in ipairs(vim.api.nvim_list_bufs()) do
if vim.api.nvim_buf_get_name(b):match("^heph://") then
pcall(vim.api.nvim_buf_delete, b, { force = true })
end
end
local h = ctx.daemon and ctx.daemon.handle
if h then
if not ctx.exited.done then
pcall(function()
h:kill("sigterm")
end)
vim.wait(2000, function()
return ctx.exited.done
end, 20)
end
pcall(function()
if not h:is_closing() then
h:close()
end
end)
end
pcall(function()
vim.fn.delete(ctx.dir, "rf")
end)
end
-- --- step builders (drive the plugin's default session) ---
function M.create_doc(title, body)
return rpc.call("node.create", { kind = "doc", title = title, body = body or "" })
end
function M.create_task(opts)
return rpc.call("task.create", opts or {})
end
--- Open node `id` in a buffer; returns the buffer handle.
function M.open(id)
require("heph.node").open(id)
return vim.api.nvim_get_current_buf()
end
function M.set_lines(buf, lines)
vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines)
end
--- Save a heph:// buffer (fires BufWriteCmd → node.update).
function M.save(buf)
vim.api.nvim_buf_call(buf, function()
vim.cmd("write")
end)
end
return M

View file

@ -0,0 +1,32 @@
-- Workflow (b): create a daily journal, write an entry, save, assert persisted.
local h = require("e2e.helpers")
describe("journal", function()
local ctx
before_each(function()
ctx = h.start()
end)
after_each(function()
h.stop(ctx)
end)
it("creates the journal, persists an entry, and round-trips exactly", function()
local node = require("heph.journal").open("2026-06-01")
local buf = vim.api.nvim_get_current_buf()
assert.are.equal("heph://node/" .. node.id, vim.api.nvim_buf_get_name(buf))
assert.are.equal("journal", vim.b[buf].heph_node_kind)
h.set_lines(buf, { "Today I wrote tests." })
h.save(buf)
assert.is_false(vim.bo[buf].modified)
-- Persisted body equals the typed text exactly — the trailing-newline canary.
local stored = ctx.q:call("node.get", { id = node.id })
assert.are.equal("Today I wrote tests.", stored.body)
-- Idempotent reopen returns the same deterministic journal node.
local again = require("heph.journal").open("2026-06-01")
assert.are.equal(node.id, again.id)
end)
end)

View file

@ -0,0 +1,22 @@
--- Headless entry point for the e2e suite. Bootstraps the runtimepath and
--- package.path, loads the plugin, runs every `*_spec.lua` in this directory,
--- and exits non-zero if any test failed (so CI fails honestly).
---
--- nvim --headless -u NONE -c "luafile tests/e2e/run.lua"
local here = vim.fn.fnamemodify(debug.getinfo(1, "S").source:sub(2), ":p")
local root = vim.fn.fnamemodify(here, ":h:h:h") -- .../heph.nvim (absolute)
local e2e = root .. "/tests/e2e"
vim.opt.runtimepath:append(root)
package.path = root .. "/tests/?.lua;" .. root .. "/tests/?/init.lua;" .. package.path
vim.cmd("runtime plugin/heph.lua")
local runner = require("e2e.runner")
runner.install_globals()
local files = vim.fn.glob(e2e .. "/*_spec.lua", false, true)
table.sort(files)
local failed = runner.run_files(files)
vim.cmd(failed > 0 and "cquit 1" or "quit")

View file

@ -0,0 +1,140 @@
--- A tiny, dependency-free busted-compatible test runner (tech-spec §9 sanctions
--- "drive nvim ... from the test runner"). Provides the `describe`/`it`/
--- `before_each`/`after_each` globals and a luassert-style `assert` table, then
--- runs `*_spec.lua` files in headless Neovim with proper exit codes — no
--- external plugins, no network, nothing vendored.
local M = {}
-- Registration state (module-local; the installed globals close over these).
local cases = {}
local scope_stack = {}
local function full_name(leaf)
local parts = {}
for _, s in ipairs(scope_stack) do
if s.name then
parts[#parts + 1] = s.name
end
end
parts[#parts + 1] = leaf
return table.concat(parts, " ")
end
--- Install the spec globals. `assert` becomes a callable luassert-ish table:
--- `assert(cond, msg)` still works (so harness code using the builtin is fine),
--- plus `assert.are.equal`, `assert.is_true/is_false/is_truthy/is_falsy`.
function M.install_globals()
_G.describe = function(name, fn)
table.insert(scope_stack, { name = name, befores = {}, afters = {} })
fn()
table.remove(scope_stack)
end
_G.before_each = function(fn)
table.insert(scope_stack[#scope_stack].befores, fn)
end
_G.after_each = function(fn)
table.insert(scope_stack[#scope_stack].afters, fn)
end
_G.it = function(name, fn)
-- Snapshot the active before/after chain (outermost-first for befores,
-- innermost-first for afters), as busted runs them per-test.
local befores, afters = {}, {}
for _, s in ipairs(scope_stack) do
for _, b in ipairs(s.befores) do
befores[#befores + 1] = b
end
end
for i = #scope_stack, 1, -1 do
for _, a in ipairs(scope_stack[i].afters) do
afters[#afters + 1] = a
end
end
table.insert(cases, { name = full_name(name), fn = fn, befores = befores, afters = afters })
end
local function fail(msg, level)
error(msg, (level or 1) + 1)
end
local A = setmetatable({}, {
__call = function(_, cond, msg)
if not cond then
fail(msg or "assertion failed")
end
return cond
end,
})
local function eq(a, b, msg)
if a ~= b then
fail(msg or string.format("expected %s, got %s", vim.inspect(b), vim.inspect(a)))
end
end
A.are = { equal = eq, equals = eq }
A.equals = eq
A.equal = eq
A.is_true = function(x, msg)
if x ~= true then
fail(msg or ("expected true, got " .. vim.inspect(x)))
end
end
A.is_false = function(x, msg)
if x ~= false then
fail(msg or ("expected false, got " .. vim.inspect(x)))
end
end
A.is_truthy = function(x, msg)
if not x then
fail(msg or ("expected truthy, got " .. vim.inspect(x)))
end
end
A.is_falsy = function(x, msg)
if x then
fail(msg or ("expected falsy, got " .. vim.inspect(x)))
end
end
_G.assert = A
end
--- Run each spec file, returning the number of failed tests. Prints TAP-ish
--- lines so failures are obvious in CI logs.
function M.run_files(files)
local passed, failed = 0, 0
for _, file in ipairs(files) do
cases = {}
scope_stack = {}
local loaded, lerr = pcall(dofile, file)
if not loaded then
failed = failed + 1
print("not ok - load " .. file)
print(" " .. tostring(lerr):gsub("\n", "\n "))
end
for _, c in ipairs(cases) do
local ok, err = true, nil
for _, b in ipairs(c.befores) do
local bok, berr = pcall(b)
if not bok then
ok, err = false, berr
break
end
end
if ok then
ok, err = pcall(c.fn)
end
for _, a in ipairs(c.afters) do
pcall(a) -- teardown always runs, even after a failure
end
if ok then
passed = passed + 1
print("ok - " .. c.name)
else
failed = failed + 1
print("not ok - " .. c.name)
print(" " .. tostring(err):gsub("\n", "\n "))
end
end
end
print(string.format("\n%d passed, %d failed", passed, failed))
return failed
end
return M