diff --git a/README.md b/README.md index f87a9b0..e0b4162 100644 --- a/README.md +++ b/README.md @@ -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 11b–11c) | ⏳ next | ## Architecture diff --git a/crates/heph-core/src/sqlite/links.rs b/crates/heph-core/src/sqlite/links.rs index b29d3f2..0557464 100644 --- a/crates/heph-core/src/sqlite/links.rs +++ b/crates/heph-core/src/sqlite/links.rs @@ -123,7 +123,7 @@ pub(super) fn sync_wiki_links( let mut desired: Vec = Vec::new(); let mut desired_set: HashSet = 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> { +/// 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> { let by_alias: Option = conn .query_row( "SELECT n.id FROM aliases a JOIN nodes n ON n.id = a.node_id diff --git a/crates/heph-core/src/sqlite/mod.rs b/crates/heph-core/src/sqlite/mod.rs index ee160fb..822da3b 100644 --- a/crates/heph-core/src/sqlite/mod.rs +++ b/crates/heph-core/src/sqlite/mod.rs @@ -200,6 +200,13 @@ impl Store for LocalStore { nodes::tombstone(&self.conn, &self.owner_id, now, id) } + fn resolve_node(&self, title: &str) -> Result> { + 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 { 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(); diff --git a/crates/heph-core/src/store.rs b/crates/heph-core/src/store.rs index 162cf70..a9c50f7 100644 --- a/crates/heph-core/src/store.rs +++ b/crates/heph-core/src/store.rs @@ -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>; + // --- tasks --- /// Create a committed task, auto-creating its canonical context `doc` and diff --git a/crates/hephd/src/remote.rs b/crates/hephd/src/remote.rs index b4734dc..d777625 100644 --- a/crates/hephd/src/remote.rs +++ b/crates/hephd/src/remote.rs @@ -133,6 +133,10 @@ impl Store for RemoteStore { self.call("node.tombstone", json!({ "id": id })).map(|_| ()) } + fn resolve_node(&self, title: &str) -> Result> { + self.call_as("node.resolve", json!({ "title": title })) + } + fn create_task(&mut self, input: NewTask) -> Result { self.call_as("task.create", json!(input)) } diff --git a/crates/hephd/src/rpc.rs b/crates/hephd/src/rpc.rs index e1ca489..874b98b 100644 --- a/crates/hephd/src/rpc.rs +++ b/crates/hephd/src/rpc.rs @@ -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 { + let p: ResolveParams = parse(params)?; + json!(store.resolve_node(&p.title)?) + } "task.create" => { let p: NewTask = parse(params)?; json!(store.create_task(p)?) diff --git a/crates/hephd/tests/rpc_socket.rs b/crates/hephd/tests/rpc_socket.rs index 8951167..47abfa9 100644 --- a/crates/hephd/tests/rpc_socket.rs +++ b/crates/hephd/tests/rpc_socket.rs @@ -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(); diff --git a/docs/changelog.d/v1-prototype.feature.md b/docs/changelog.d/v1-prototype.feature.md index c874259..a1a770d 100644 --- a/docs/changelog.d/v1-prototype.feature.md +++ b/docs/changelog.d/v1-prototype.feature.md @@ -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 --oidc-audience ` (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 --issuer --client-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/` with `BufReadCmd`→`node.get` / `BufWriteCmd`→`node.update`, whole-buffer body round-tripping exactly through the CRDT); `[[wiki-link]]` follow on `` 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. diff --git a/docs/reference/heph-nvim.md b/docs/reference/heph-nvim.md new file mode 100644 index 0000000..0a5854e --- /dev/null +++ b/docs/reference/heph-nvim.md @@ -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/` 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 ` 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 ` | Open a dated journal | +| `:Heph follow` (also `` in a node buffer) | Follow the `[[link]]` under the cursor | +| `:Heph open ` | 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 diff --git a/docs/reference/reference.md b/docs/reference/reference.md index 1117159..bbbd30e 100644 --- a/docs/reference/reference.md +++ b/docs/reference/reference.md @@ -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 diff --git a/docs/reference/tech-spec.md b/docs/reference/tech-spec.md index 82f064d..aa9e5ea 100644 --- a/docs/reference/tech-spec.md +++ b/docs/reference/tech-spec.md @@ -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/` buffers (`buftype=acwrite`), `BufReadCmd`→`node.get` / `BufWriteCmd`→`node.update` (whole-buffer body, CRDT-diffed; exact round-trip). **`[[wiki-link]]` follow** on `` 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 `, 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 diff --git a/heph.nvim/Makefile b/heph.nvim/Makefile new file mode 100644 index 0000000..0243c55 --- /dev/null +++ b/heph.nvim/Makefile @@ -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" diff --git a/heph.nvim/README.md b/heph.nvim/README.md new file mode 100644 index 0000000..173e67c --- /dev/null +++ b/heph.nvim/README.md @@ -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/`. 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 `` 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, -- h* maps + -- autostart = false, -- spawn hephd if absent +}) +``` + +## Commands + +| Command | Action | +|---|---| +| `:Heph today` | Open today's journal | +| `:Heph journal ` | Open a dated journal | +| `:Heph follow` | Follow the `[[link]]` under the cursor (also ``) | +| `:Heph open ` | 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`. diff --git a/heph.nvim/lua/heph/command.lua b/heph.nvim/lua/heph/command.lua new file mode 100644 index 0000000..8953d63 --- /dev/null +++ b/heph.nvim/lua/heph/command.lua @@ -0,0 +1,62 @@ +--- The `:Heph ` 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 diff --git a/heph.nvim/lua/heph/config.lua b/heph.nvim/lua/heph/config.lua new file mode 100644 index 0000000..9453e1a --- /dev/null +++ b/heph.nvim/lua/heph/config.lua @@ -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 `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", "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 diff --git a/heph.nvim/lua/heph/daemon.lua b/heph.nvim/lua/heph/daemon.lua new file mode 100644 index 0000000..c3ff8e8 --- /dev/null +++ b/heph.nvim/lua/heph/daemon.lua @@ -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 diff --git a/heph.nvim/lua/heph/init.lua b/heph.nvim/lua/heph/init.lua new file mode 100644 index 0000000..ea54747 --- /dev/null +++ b/heph.nvim/lua/heph/init.lua @@ -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 diff --git a/heph.nvim/lua/heph/journal.lua b/heph.nvim/lua/heph/journal.lua new file mode 100644 index 0000000..389ab81 --- /dev/null +++ b/heph.nvim/lua/heph/journal.lua @@ -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 diff --git a/heph.nvim/lua/heph/link.lua b/heph.nvim/lua/heph/link.lua new file mode 100644 index 0000000..6ef598a --- /dev/null +++ b/heph.nvim/lua/heph/link.lua @@ -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 `` follow keymap (only on heph:// buffers). +function M.attach(buf) + vim.keymap.set("n", "", function() + M.follow() + end, { buffer = buf, desc = "heph: follow [[link]]" }) +end + +return M diff --git a/heph.nvim/lua/heph/node.lua b/heph.nvim/lua/heph/node.lua new file mode 100644 index 0000000..05a812c --- /dev/null +++ b/heph.nvim/lua/heph/node.lua @@ -0,0 +1,52 @@ +--- Buffer-backed nodes (tech-spec §8): a node's markdown body is edited in a +--- real buffer named `heph://node/`. `: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/`: 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 diff --git a/heph.nvim/lua/heph/rpc.lua b/heph.nvim/lua/heph/rpc.lua new file mode 100644 index 0000000..3a71b4d --- /dev/null +++ b/heph.nvim/lua/heph/rpc.lua @@ -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 diff --git a/heph.nvim/lua/heph/util.lua b/heph.nvim/lua/heph/util.lua new file mode 100644 index 0000000..3e22a0b --- /dev/null +++ b/heph.nvim/lua/heph/util.lua @@ -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:///` 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 diff --git a/heph.nvim/plugin/heph.lua b/heph.nvim/plugin/heph.lua new file mode 100644 index 0000000..40502e2 --- /dev/null +++ b/heph.nvim/plugin/heph.lua @@ -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/` 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, +}) diff --git a/heph.nvim/tests/e2e/backlink_spec.lua b/heph.nvim/tests/e2e/backlink_spec.lua new file mode 100644 index 0000000..04e6072 --- /dev/null +++ b/heph.nvim/tests/e2e/backlink_spec.lua @@ -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) diff --git a/heph.nvim/tests/e2e/follow_link_spec.lua b/heph.nvim/tests/e2e/follow_link_spec.lua new file mode 100644 index 0000000..d9395cf --- /dev/null +++ b/heph.nvim/tests/e2e/follow_link_spec.lua @@ -0,0 +1,40 @@ +-- Workflow (c): follow a [[link]] under the cursor on 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) diff --git a/heph.nvim/tests/e2e/helpers.lua b/heph.nvim/tests/e2e/helpers.lua new file mode 100644 index 0000000..2658ea6 --- /dev/null +++ b/heph.nvim/tests/e2e/helpers.lua @@ -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 diff --git a/heph.nvim/tests/e2e/journal_spec.lua b/heph.nvim/tests/e2e/journal_spec.lua new file mode 100644 index 0000000..337c57b --- /dev/null +++ b/heph.nvim/tests/e2e/journal_spec.lua @@ -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) diff --git a/heph.nvim/tests/e2e/run.lua b/heph.nvim/tests/e2e/run.lua new file mode 100644 index 0000000..0b71254 --- /dev/null +++ b/heph.nvim/tests/e2e/run.lua @@ -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") diff --git a/heph.nvim/tests/e2e/runner.lua b/heph.nvim/tests/e2e/runner.lua new file mode 100644 index 0000000..bc1d124 --- /dev/null +++ b/heph.nvim/tests/e2e/runner.lua @@ -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