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