diff --git a/.dagger/src/hephaestus_ci/main.py b/.dagger/src/hephaestus_ci/main.py index 091a1e7..b485922 100644 --- a/.dagger/src/hephaestus_ci/main.py +++ b/.dagger/src/hephaestus_ci/main.py @@ -1,11 +1,6 @@ import dagger from dagger import dag, function, object_type -# Pinned Neovim — Debian's packaged nvim is far too old for `vim.uv` (the plugin -# needs >= 0.10), so the e2e container bakes an official release tarball. The -# arch is detected at build time so this runs natively on amd64 (CI) and arm64. -NVIM_VERSION = "v0.11.2" - @object_type class HephaestusCi: @@ -14,7 +9,7 @@ class HephaestusCi: """Rust workspace checks: rustfmt, clippy (-D warnings), and the full test suite (tech-spec §9). Runs on `rust:1-bookworm` (glibc + a C toolchain for rusqlite's bundled SQLite); the Alpine job image only - orchestrates `dagger call`. Shares cargo caches with `test_nvim`. + orchestrates `dagger call`. """ return await ( dag.container() @@ -46,56 +41,6 @@ class HephaestusCi: .stdout() ) - @function - async def test_nvim(self, src: dagger.Directory) -> str: - """Run the heph.nvim headless e2e suite (tech-spec §9). - - Builds the hephd daemon and drives the plugin in headless Neovim against - it, using the repo's self-contained busted-style runner (no external nvim - plugins, no network at test time). Fails non-zero if any spec fails; - returns the suite output. Dev runs the same suite natively via - `mise run test-nvim`; this is the reproducible CI path. - """ - return await ( - dag.container() - .from_("rust:1-bookworm") - .with_exec(["apt-get", "update", "-qq"]) - .with_exec(["apt-get", "install", "-y", "-qq", "curl", "ca-certificates"]) - # Cache cargo downloads + build artifacts across CI runs. - .with_mounted_cache( - "/usr/local/cargo/registry", - dag.cache_volume("heph-cargo-registry"), - ) - .with_exec( - [ - "sh", - "-c", - "set -e; " - 'case "$(uname -m)" in ' - "x86_64) arch=x86_64 ;; " - "aarch64|arm64) arch=arm64 ;; " - '*) echo "unsupported arch: $(uname -m)" >&2; exit 1 ;; ' - "esac; " - "curl -fsSL " - f"https://github.com/neovim/neovim/releases/download/{NVIM_VERSION}/nvim-linux-$arch.tar.gz " - "| tar -xz -C /opt; " - "ln -s /opt/nvim-linux-$arch /opt/nvim", - ] - ) - .with_env_variable("PATH", "/opt/nvim/bin:$PATH", expand=True) - .with_env_variable("CARGO_BUILD_JOBS", "4") - .with_directory("/workspace", src) - .with_workdir("/workspace") - .with_mounted_cache("/workspace/target", dag.cache_volume("heph-target")) - .with_exec(["cargo", "build", "-p", "hephd"]) - .with_env_variable("HEPHD_BIN", "/workspace/target/debug/hephd") - .with_workdir("/workspace/heph.nvim") - .with_exec( - ["nvim", "--headless", "-u", "NONE", "-c", "luafile tests/e2e/run.lua"] - ) - .stdout() - ) - @function async def build_docs(self, src: dagger.Directory, version: str) -> dagger.File: """Build Quartz docs site. Returns docs tarball.""" diff --git a/.forgejo/workflows/build.yaml b/.forgejo/workflows/build.yaml index ae265e7..4a462bf 100644 --- a/.forgejo/workflows/build.yaml +++ b/.forgejo/workflows/build.yaml @@ -1,15 +1,14 @@ # Build Workflow # # CI validation, run entirely through Dagger. The Forgejo job image is a thin -# Alpine + Dagger orchestrator (no Rust/Neovim toolchain), so all build and test -# work happens in Dagger containers (.dagger/src/hephaestus_ci/main.py): +# Alpine + Dagger orchestrator (no Rust toolchain), so all build and test work +# happens in Dagger containers (.dagger/src/hephaestus_ci/main.py): # -# - check — cargo fmt --check, clippy -D warnings, cargo test --all -# - test-nvim — build hephd + run the headless heph.nvim e2e suite +# - check — cargo fmt --check, clippy -D warnings, cargo test --all # -# prek is intentionally not run here — it runs locally via git hooks -# (`prek install`). Dagger uses the DinD sidecar; both functions share cargo -# cache volumes across runs. +# The Neovim plugin and its headless e2e suite live in their own repo +# (eblume/hephaestus.nvim). prek is intentionally not run here — it runs locally +# via git hooks (`prek install`). Dagger uses the DinD sidecar. name: Build @@ -30,8 +29,3 @@ jobs: run: | echo "Running cargo fmt/clippy/test via Dagger..." dagger call check --src=. - - - name: heph.nvim e2e (Dagger) - run: | - echo "Running headless heph.nvim e2e suite via Dagger..." - dagger call test-nvim --src=. diff --git a/.stylua.toml b/.stylua.toml deleted file mode 100644 index 9482c7c..0000000 --- a/.stylua.toml +++ /dev/null @@ -1,5 +0,0 @@ -# Opinionated Lua formatting for heph.nvim, enforced via prek's stylua hook. -# Spaces/2 matches the existing plugin style; everything else is stylua's -# defaults (the formatter is the source of truth — don't hand-format). -indent_type = "Spaces" -indent_width = 2 diff --git a/AGENTS.md b/AGENTS.md index b799c0c..c95c846 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,7 +41,7 @@ See [[agent-change-process]] for the full methodology. ## Project Structure -A Cargo workspace (`Cargo.toml` at root) plus the Neovim plugin and repo tooling. Backend feature-complete (all three runtime modes + sync + OIDC); three daily-driver surfaces — `heph` (CLI), `heph-tui` (agenda/triage), `heph.nvim` (context/KB). +A Cargo workspace (`Cargo.toml` at root) plus repo tooling. Backend feature-complete (all three runtime modes + sync + OIDC); three daily-driver surfaces — `heph` (CLI) and `heph-tui` (agenda/triage) in this workspace, plus the `hephaestus.nvim` Neovim plugin (context/KB) in its own forge repo, [eblume/hephaestus.nvim](ssh://forgejo@forge.ops.eblu.me:2222/eblume/hephaestus.nvim.git). ``` ./Cargo.toml # workspace manifest (shared deps + members) @@ -49,7 +49,6 @@ A Cargo workspace (`Cargo.toml` at root) plus the Neovim plugin and repo tooling # recurrence, "what is next?" ranking, op-log/HLC/CRDT (yrs) sync ./crates/hephd/ # daemon: local/server/client modes — unix-socket RPC + HTTP sync/rpc + OIDC auth ./crates/heph/ # CLI (thin client of hephd): next/task/doc/get/export/search/journal/auth -./heph.nvim/ # Neovim plugin: primary surface; replaces obsidian.nvim (Lua + headless e2e) ./docs/ # Diataxis docs (incl. [[design]] + [[v1-prototype-tech-spec]]), Quartz config, release content ./docs/changelog.d/ # towncrier fragments for noteworthy changes ./.dagger/ # Dagger module (src/hephaestus_ci/) backing docs builds and releases diff --git a/README.md b/README.md index 9fcd95a..dc931f2 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ A Cargo workspace, layered so the same core runs from a laptop to a hub: - **`crates/heph-core`** — the library: data model, the `Store` trait + SQLite store, markdown parsing/extraction, recurrence, the "what is next?" engine, and the sync engine (op-log, hybrid logical clocks, CRDT/LWW merge, conflict detection). Synchronous and clock-injected (no ambient wall-clock reads) so ranking and merge are deterministic. - **`crates/hephd`** — the per-device daemon. One binary, three modes — **`local`** (own SQLite replica; a syncing spoke when given `--hub-url`), **`server`** (also the sync hub: an HTTP endpoint others sync against), **`client`** (thin, remote, no replica — proxies to a `--server-url`) — selected by configuration via a targetable `Store` backend. Surfaces connect to it over a unix socket; it owns the DB handle and background sync. - **`crates/heph`** — the CLI: a thin client of the daemon (no direct DB access). -- **`heph.nvim/`** — the Neovim plugin, the primary editing/agenda surface (a thin client of the daemon; see [docs/reference/heph-nvim.md](docs/reference/heph-nvim.md) and [docs/how-to/install-heph.md](docs/how-to/install-heph.md)). +- **`hephaestus.nvim`** (its own forge repo, [eblume/hephaestus.nvim](ssh://forgejo@forge.ops.eblu.me:2222/eblume/hephaestus.nvim.git)) — the Neovim editing/knowledge-base surface, a thin client of the daemon; see [docs/reference/heph-nvim.md](docs/reference/heph-nvim.md) and [docs/how-to/install-heph.md](docs/how-to/install-heph.md). **Storage:** SQLite is the source of truth; a node's body is markdown; `export` materializes the whole store as a directory of `.md` files. **Sync:** each device holds a full replica + an append-only op-log; devices reconcile through a hub with automatic merge (text-CRDT bodies, last-writer-wins scalars, OR-set links) and a conflict queue for the ambiguous remainder. **Auth:** the hub verifies an OIDC bearer token (Authentik) on every op exchange — RS256/JWKS verification + a single-tenant owner gate — and clients obtain tokens via the OAuth 2.0 device-code flow (`heph auth login`), cached in the OS keyring. Local-only instances need no auth. @@ -91,7 +91,6 @@ mise run ai-docs # docs AI agents read firs ./crates/heph-core/ # core library: model, store, extraction, recurrence, ranking, sync ./crates/hephd/ # daemon: local/server/client modes — unix-socket RPC + HTTP sync/rpc ./crates/heph/ # CLI: thin client of the daemon -./heph.nvim/ # Neovim plugin (primary surface) — Lua, headless e2e harness ./docs/ # Diataxis docs (design, v1-prototype-tech-spec, how-to), Quartz config ./.forgejo/ # CI build + release workflows and hooks ./.dagger/ # Dagger module backing docs builds/releases diff --git a/docs/changelog.d/extract-heph-nvim.infra.md b/docs/changelog.d/extract-heph-nvim.infra.md new file mode 100644 index 0000000..5c69204 --- /dev/null +++ b/docs/changelog.d/extract-heph-nvim.infra.md @@ -0,0 +1 @@ +Extracted the Neovim plugin into its own forge repo, [eblume/hephaestus.nvim](ssh://forgejo@forge.ops.eblu.me:2222/eblume/hephaestus.nvim.git). Removed `heph.nvim/` from the monorepo along with its build/test wiring: the `test_nvim` Dagger function, the `dagger call test-nvim` CI step, the `mise run test-nvim` task, and the `.stylua.toml` + stylua prek hook (no Lua remains here). The CLI/TUI→nvim integration is unchanged (they shell out to `nvim` expecting the plugin installed). Install now uses a plain lazy.nvim spec pointing at the plugin repo — see [[install-heph]]. diff --git a/docs/how-to/install-heph.md b/docs/how-to/install-heph.md index 8490a06..820146b 100644 --- a/docs/how-to/install-heph.md +++ b/docs/how-to/install-heph.md @@ -33,20 +33,15 @@ real data. ## 2. The Neovim plugin -`heph.nvim` lives in a subdirectory of the monorepo, and lazy.nvim can't load a -subdir plugin from a bare git URL (it puts the clone *root* on `runtimepath`). -For now, point lazy at a dedicated checkout via `dir`: - -```bash -git clone --branch feature/v1-prototype \ - ssh://forgejo@forge.ops.eblu.me:2222/eblume/hephaestus.git \ - ~/.local/share/heph/checkout -``` +The plugin lives in its own forge repo, +[eblume/hephaestus.nvim](ssh://forgejo@forge.ops.eblu.me:2222/eblume/hephaestus.nvim.git), +with the plugin at the repo root — so lazy.nvim loads it from a bare git URL. +Point lazy at the forge over SSH: ```lua -- lazy.nvim spec { - dir = vim.fn.expand("~/.local/share/heph/checkout/heph.nvim"), + url = "ssh://forgejo@forge.ops.eblu.me:2222/eblume/hephaestus.nvim.git", config = function() require("heph").setup({}) -- connect-only: talks to the daemon you started end, @@ -61,9 +56,8 @@ heph daemon start # launchd agent (macOS) / systemd user service (Linux) ``` See [[run-the-daemon]] for `start`/`stop`/`restart`/`status`. Update the plugin -by `git pull`ing the checkout (and after a `cargo install` upgrade, `heph daemon -restart` to pick up the new `hephd`). (A future split of `heph.nvim` into its own -forge repo will make this a normal `{ "eblume/heph.nvim" }` spec.) +with `:Lazy update` (and after a `cargo install` upgrade of the binaries, `heph +daemon restart` to pick up the new `hephd`). ## 3. Isolate development diff --git a/docs/reference/heph-nvim.md b/docs/reference/heph-nvim.md index 9d79c68..83311ae 100644 --- a/docs/reference/heph-nvim.md +++ b/docs/reference/heph-nvim.md @@ -8,15 +8,20 @@ tags: # heph.nvim -The primary user surface (tech-spec §8): a Neovim plugin that replaces +The Neovim knowledge-base surface (tech-spec §8): a 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. +buffers; the daemon owns all storage and sync. + +> **Repo:** the plugin code lives in its own forge repo, +> [eblume/hephaestus.nvim](ssh://forgejo@forge.ops.eblu.me:2222/eblume/hephaestus.nvim.git) +> (extracted from this monorepo). This card documents the surface's +> architecture; see [[install-heph]] to install it and the plugin repo for the +> Lua sources + headless e2e suite. ## Architecture -`heph.nvim/lua/heph/` modules, each small and single-purpose: +The `lua/heph/` modules, each small and single-purpose: | Module | Responsibility | |---|---| @@ -109,13 +114,11 @@ 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 it is -deterministic. `mise run test-nvim` builds the daemon and runs the suite against -system-installed Neovim; a deliberately failing spec exits non-zero (no -false-green; the runner also fails if it discovers zero specs). CI runs the same -suite through the **`test-nvim` Dagger function** (`.dagger/`, invoked by -`build.yaml` as `dagger call test-nvim`), which bakes a pinned, arch-detected -Neovim onto a Rust image, builds `hephd`, and runs the suite — reproducible, and -identical to the native `mise run test-nvim` path. +deterministic. The suite lives in the plugin repo (eblume/hephaestus.nvim); since +that repo carries no Rust source, `mise run test-nvim` there drives the suite +against a prebuilt `hephd` supplied via `$HEPHD_BIN` (built from this monorepo: +`cargo build -p hephd`). A deliberately failing spec exits non-zero, and the +runner also fails if it discovers zero specs — no false-green. ## Related diff --git a/heph.nvim/README.md b/heph.nvim/README.md deleted file mode 100644 index 9854b44..0000000 --- a/heph.nvim/README.md +++ /dev/null @@ -1,75 +0,0 @@ -# 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 Neovim ≥ 0.10 and `hephd` on `PATH` (e.g. `cargo install`ed). By -default the plugin is **plug-and-play** — it starts and manages its own `hephd`: - -```lua -require("heph").setup({}) -- spawns a local hephd against the default XDG paths -``` - -- **`autostart = true`** (default): if nothing is serving the socket, the plugin - spawns a local `hephd`, kills only what *it* spawned on exit, and **self-heals** - (respawns + reconnects if the daemon dies mid-session). -- **Running your own daemon** (a `server`/`client` architecture, or a launchd - service)? Set `autostart = false` and point at its socket — the plugin then - **connects only**, never spawning over your daemon, and warns if it's - unreachable. A daemon already serving the socket is always respected, even with - `autostart = true` (the plugin only spawns when nothing is there). - -```lua -require("heph").setup({ - -- socket = "...", -- default: $HEPH_SOCKET, else hephd's XDG path - -- db = "...", -- DB for an autostarted daemon ($HEPH_DB, else default) - -- autostart = true, -- false = connect-only (you run hephd yourself) - -- bin = "hephd", -- daemon binary for autostart - -- keymaps = true, -- h* maps -}) -``` - -**Dev isolation:** set `$HEPH_SOCKET` / `$HEPH_DB` (or run `mise run dev`) so a -development Neovim drives a separate daemon + DB and never touches real data. - -## 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 -mise run test-nvim # 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. Dev runs use system-installed Neovim (≥ 0.10) + rustc; CI runs -the same suite inside a Dagger container that provides them (slice 11c). diff --git a/heph.nvim/lua/heph/command.lua b/heph.nvim/lua/heph/command.lua deleted file mode 100644 index 620ebb9..0000000 --- a/heph.nvim/lua/heph/command.lua +++ /dev/null @@ -1,155 +0,0 @@ ---- 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 = {} - -local ATTENTIONS = { "white", "orange", "red", "blue" } - ---- subcommand -> handler(args: string[]) -M.subs = { - -- knowledge base - home = function() - local cfg = require("heph").config or {} - require("heph.home").open(cfg.home) - end, - today = function() - require("heph.journal").open() - end, - journal = function(args) - require("heph.journal").open(args[1]) - end, - journals = function() - require("heph.journal").pick() - end, - follow = function() - require("heph.link").follow() - end, - link = function() - require("heph.link").insert() - end, - open = function(args) - if args[1] then - require("heph.node").open(args[1]) - end - end, - doc = function(args) - local title = table.concat(args, " ") - if #title == 0 then - require("heph.util").notify("usage: :Heph doc ", vim.log.levels.WARN) - return - end - local node = require("heph.rpc").call("node.create", { kind = "doc", title = title, body = "" }) - require("heph.node").open(node.id) - end, - search = function(args) - local query = table.concat(args, " ") - if #query == 0 then - require("heph.util").notify("usage: :Heph search <query>", vim.log.levels.WARN) - return - end - local nodes = require("heph.rpc").call("search", { query = query }) - require("heph.picker").select(nodes, { - prompt = "heph search: " .. query, - format = function(n) - return string.format("[%s] %s", n.kind, n.title) - end, - }, function(choice) - if choice then - require("heph.node").open(choice.id) - end - end) - end, - - -- tasks - next = function(args) - require("heph.view").next({ scope = args[1] }) - end, - list = function(args) - require("heph.view").list({ attention = args[1] }) - end, - view = function(args) - local name = args[1] - if not name then - require("heph.util").notify("usage: :Heph view <tom|ondeck|chores|work|tasks>", vim.log.levels.WARN) - return - end - require("heph.view").view(name) - end, - capture = function(args) - local title = table.concat(args, " ") - if #title == 0 then - require("heph.util").notify("usage: :Heph capture <title>", vim.log.levels.WARN) - return - end - require("heph.picker").select(ATTENTIONS, { prompt = "attention for: " .. title }, function(attention) - require("heph.task").capture(title, { attention = attention }) - require("heph.util").notify("captured: " .. title) - end) - end, - attention = function(args) - if args[1] then - require("heph.task").set_attention_current(args[1]) - else - require("heph.picker").select(ATTENTIONS, { prompt = "attention" }, function(choice) - if choice then - require("heph.task").set_attention_current(choice) - end - end) - end - end, - done = function() - require("heph.task").set_state_current("done") - end, - drop = function() - require("heph.task").set_state_current("dropped") - end, - skip = function() - require("heph.task").skip_current() - end, - promote = function(args) - require("heph.task").promote_under_cursor({ attention = args[1] }) - end, - log = function(args) - require("heph.task").log_append_current(table.concat(args, " ")) - 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/conceal.lua b/heph.nvim/lua/heph/conceal.lua deleted file mode 100644 index d4c14c1..0000000 --- a/heph.nvim/lua/heph/conceal.lua +++ /dev/null @@ -1,66 +0,0 @@ ---- Conceal `[[NODEID|Name]]` links down to a styled "Name" hyperlink in node ---- buffers (tech-spec §8.4). The node id is structural noise to a reader, so we ---- hide the `[[id|` prefix and the `]]` suffix with conceal extmarks, leaving ---- the label visible and highlighted. `conceallevel=2` + an empty ---- `concealcursor` reveal the raw `[[id|Name]]` on the line the cursor is on, so ---- it stays directly editable. A bare `[[id]]` (briefly, before save→reload ---- canonicalises it) just hides its brackets. - -local M = {} - -local ns = vim.api.nvim_create_namespace("heph_link_conceal") - ---- Recompute conceal extmarks for every `[[…]]` span in `buf`. -function M.refresh(buf) - if not vim.api.nvim_buf_is_valid(buf) then - return - end - vim.api.nvim_buf_clear_namespace(buf, ns, 0, -1) - local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) - for lnum, line in ipairs(lines) do - local row = lnum - 1 - local from = 1 - while true do - local s = line:find("[[", from, true) -- 1-based byte of first `[` - if not s then - break - end - local e = line:find("]]", s + 2, true) -- 1-based byte of `]]` - if not e then - break - end - local inner = line:sub(s + 2, e - 1) - local pipe = inner:find("|", 1, true) - -- Byte columns are 0-based for extmarks; end_col is exclusive. - local open_end -- exclusive end of the hidden prefix (`[[` or `[[id|`) - if pipe then - open_end = (s + 1 + pipe) -- 0-based col just past the `|` - else - open_end = s + 1 -- 0-based col just past `[[` - end - -- Hide the prefix, highlight the visible label, hide the closing `]]`. - vim.api.nvim_buf_set_extmark(buf, ns, row, s - 1, { end_col = open_end, conceal = "" }) - vim.api.nvim_buf_set_extmark(buf, ns, row, open_end, { end_col = e - 1, hl_group = "HephLink" }) - vim.api.nvim_buf_set_extmark(buf, ns, row, e - 1, { end_col = e + 1, conceal = "" }) - from = e + 2 - end - end -end - ---- Enable link conceal for the current window + `buf`: define the highlight, ---- set the window conceal options, refresh now, and refresh on edits. -function M.attach(buf) - vim.api.nvim_set_hl(0, "HephLink", { link = "Underlined", default = true }) - vim.wo.conceallevel = 2 - vim.wo.concealcursor = "" -- reveal the raw link on the cursor's line - M.refresh(buf) - vim.api.nvim_create_autocmd({ "TextChanged", "TextChangedI" }, { - buffer = buf, - callback = function() - M.refresh(buf) - end, - desc = "heph: refresh [[link]] conceal", - }) -end - -return M diff --git a/heph.nvim/lua/heph/config.lua b/heph.nvim/lua/heph/config.lua deleted file mode 100644 index 0a951dd..0000000 --- a/heph.nvim/lua/heph/config.lua +++ /dev/null @@ -1,61 +0,0 @@ ---- Configuration defaults, socket resolution, and default keymaps. - -local M = {} - -M.defaults = { - --- Path to hephd's unix socket. `nil` → `$HEPH_SOCKET`, else the daemon default. - --- The plugin is connect-only; run the daemon with `heph daemon start`. - socket = nil, - --- Title of the home / index page (`:Heph home`). - home = "Home", - --- How many recent days the `:Heph journals` picker offers. - journal_days = 7, - --- Set the default `<leader>h*` keymaps. `false` to opt out. - keymaps = true, -} - ---- Resolve the socket path: explicit opt, then `$HEPH_SOCKET`, then hephd's ---- default — `$XDG_RUNTIME_DIR/heph/hephd.sock`, else a **stable** ---- `<data-dir>/heph/hephd.sock` (matching `hephd::default_socket_path`; not a ---- temp dir, since the daemon is a persistent service). `$HEPH_SOCKET` lets a ---- dev Neovim target a `mise run dev` daemon without touching real data. -function M.resolve_socket(opt) - opt = (opt and #opt > 0) and opt or vim.env.HEPH_SOCKET - if opt and #opt > 0 then - return opt - end - local xdg = vim.env.XDG_RUNTIME_DIR - if xdg and #xdg > 0 then - return (xdg:gsub("/+$", "")) .. "/heph/hephd.sock" - end - local data = vim.env.XDG_DATA_HOME - if not (data and #data > 0) then - data = (vim.env.HOME or "") .. "/.local/share" - end - return (data: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" }) - map("n", "<leader>hn", function() - require("heph.view").next() - end, { desc = "heph: what is next (Tactical)" }) - map("n", "<leader>hl", function() - require("heph.view").list() - end, { desc = "heph: task list (Organizational)" }) - map("n", "<leader>hd", function() - require("heph.task").set_state_current("done") - end, { desc = "heph: mark current task done" }) - map("n", "<leader>hp", function() - require("heph.task").promote_under_cursor() - end, { desc = "heph: promote context item to a task" }) -end - -return M diff --git a/heph.nvim/lua/heph/frontmatter.lua b/heph.nvim/lua/heph/frontmatter.lua deleted file mode 100644 index 6e9588c..0000000 --- a/heph.nvim/lua/heph/frontmatter.lua +++ /dev/null @@ -1,205 +0,0 @@ ---- Frontmatter as an edit surface (tech-spec §8.3). The daemon renders an ---- editable YAML block atop a node's body on read (`node.get {frontmatter}`) ---- and strips it on write; this module is the **smart-client** half: parse the ---- buffer's frontmatter and translate each changed field into the right ---- structured RPC (title→rename, attention→set_attention, dates→set_schedule, ---- project→set_project, tags→tag.add/remove). The body itself rides `node.update`. -local rpc = require("heph.rpc") - -local M = {} - -local function trim(s) - return (s:gsub("^%s+", ""):gsub("%s+$", "")) -end - ---- Unquote a YAML scalar (`"…"` with `\"`/`\\` escapes), else return it trimmed. -local function unquote(s) - s = trim(s) - local inner = s:match('^"(.*)"$') - if inner then - return (inner:gsub('\\"', '"'):gsub("\\\\", "\\")) - end - return s -end - ---- Parse a YAML flow sequence `[a, b]` (or `[]`) into a list of scalars. -local function parse_flow_seq(s) - local inner = s:match("^%[(.*)%]$") - if not inner then - return {} - end - local out = {} - for item in (inner .. ","):gmatch("(.-),") do - item = trim(item) - if item ~= "" then - out[#out + 1] = unquote(item) - end - end - return out -end - ---- Parse a leading `---` frontmatter block. Returns `(fm, body)` where `fm` is a ---- table of string fields plus `tags` (a list), or `(nil, text)` when there is ---- no conforming block (so a frontmatter-less buffer never looks like metadata). -function M.parse(text) - if text:sub(1, 4) ~= "---\n" then - return nil, text - end - local lines = vim.split(text, "\n", { plain = true }) - local close - for i = 2, #lines do - if lines[i] == "---" then - close = i - break - end - end - if not close then - return nil, text - end - local fm = { tags = {} } - for j = 2, close - 1 do - local key, val = lines[j]:match("^([%w_%-]+):%s*(.*)$") - if key == "tags" then - fm.tags = parse_flow_seq(val) - elseif key then - fm[key] = unquote(val) - end - end - local body = table.concat({ unpack(lines, close + 1) }, "\n") - return fm, body -end - ---- A `YYYY-MM-DD` string → epoch ms at **local midnight** (matching the ---- daemon's `datespec`). Returns `nil, err` on a malformed date. -local function date_to_ms(s) - local y, m, d = tostring(s):match("^(%d%d%d%d)%-(%d%d)%-(%d%d)$") - if not y then - return nil, "expected a YYYY-MM-DD date, got " .. tostring(s) - end - return os.time({ year = tonumber(y), month = tonumber(m), day = tonumber(d), hour = 0, min = 0, sec = 0 }) * 1000 -end - ---- Whitespace-prefixed inline `#hashtags` in `body`, de-duplicated in order. ---- A markdown heading (`# Title`, `## foo`) has a space after the `#`, so it ---- never matches. (Scanned across the whole body, code fences included — a ---- pragmatic v1 simplification.) -function M.hashtags(body) - local seen, out = {}, {} - for tag in (" " .. (body or "")):gmatch("%s#([%w_%-]+)") do - if not seen[tag] then - seen[tag] = true - out[#out + 1] = tag - end - end - return out -end - ---- Union of two scalar lists, order-stable, de-duplicated. -local function union(a, b) - local seen, out = {}, {} - for _, list in ipairs({ a or {}, b or {} }) do - for _, x in ipairs(list) do - if not seen[x] then - seen[x] = true - out[#out + 1] = x - end - end - end - return out -end - ---- Set difference of two scalar lists → (added, removed). -local function set_diff(old, new) - local oldset, newset = {}, {} - for _, x in ipairs(old or {}) do - oldset[x] = true - end - for _, x in ipairs(new or {}) do - newset[x] = true - end - local added, removed = {}, {} - for x in pairs(newset) do - if not oldset[x] then - added[#added + 1] = x - end - end - for x in pairs(oldset) do - if not newset[x] then - removed[#removed + 1] = x - end - end - return added, removed -end - ---- Apply the difference between `canonical` (rendered on read) and the buffer's ---- `fm` by issuing the matching RPCs. `node_id` is the opened node (title/tags ---- target); task scalars route to the owning task (`fm.task`). Inline ---- `#hashtags` in `body` are unioned into the desired tag set (so they manage ---- tags too); `fm.tags` is updated to that union so the caller caches it as the ---- new canonical. Raises on any RPC error (a mistyped `state`, an unknown ---- `project`). -function M.apply(node_id, canonical, fm, body) - canonical = canonical or {} - - -- Tags on the opened node = the frontmatter list ∪ inline #hashtags. - fm.tags = union(fm.tags, M.hashtags(body)) - local added, removed = set_diff(canonical.tags, fm.tags) - for _, t in ipairs(added) do - rpc.call("tag.add", { node_id = node_id, tag = t }) - end - for _, t in ipairs(removed) do - rpc.call("tag.remove", { node_id = node_id, tag = t }) - end - - -- Task scalars route to the owning task, if this node is or backs one. - local task = fm.task or canonical.task - if task then - if fm.attention and fm.attention ~= canonical.attention then - rpc.call("task.set_attention", { id = task, attention = fm.attention }) - end - - -- Schedule (double-option): a present value sets, a removed line clears. - local patch, changed = { id = task }, false - local function sched(field) - if fm[field] == canonical[field] then - return - end - changed = true - if fm[field] == nil or fm[field] == "" then - patch[field] = vim.NIL -- cleared - elseif field == "recurrence" then - patch[field] = fm[field] -- a raw RRULE - else - local ms, err = date_to_ms(fm[field]) - if not ms then - error("heph: " .. err) - end - patch[field] = ms - end - end - sched("do_date") - sched("late_on") - sched("recurrence") - if changed then - rpc.call("task.set_schedule", patch) - end - - if fm.state and fm.state ~= canonical.state then - rpc.call("task.set_state", { id = task, state = fm.state }) -- a typo → rpc error - end - - if fm.project ~= canonical.project then - local pid = vim.NIL -- removed / blank → unfile - if fm.project and fm.project ~= "" then - local node = rpc.call("node.resolve", { title = fm.project }) - if not node then - error("heph: no node named '" .. fm.project .. "' to file under") - end - pid = node.id - end - rpc.call("task.set_project", { id = task, project_id = pid }) - end - end -end - -return M diff --git a/heph.nvim/lua/heph/home.lua b/heph.nvim/lua/heph/home.lua deleted file mode 100644 index 6d72c5c..0000000 --- a/heph.nvim/lua/heph/home.lua +++ /dev/null @@ -1,27 +0,0 @@ ---- The home / index page (tech-spec §8): a single designated `doc` that is the ---- base landing page of the knowledge base — a stable place to grow a map of ---- content. Open-or-create by title, so the first `:Heph home` mints it and ---- every later one returns the same doc. - -local rpc = require("heph.rpc") - -local M = {} - ---- Open (creating if absent) the home page titled `title` (default "Home"). ---- Returns the node. -function M.open(title) - title = (title and #title > 0) and title or "Home" - local node = rpc.call("node.resolve", { title = title }) - if not node then - node = rpc.call("node.create", { - kind = "doc", - title = title, - body = "# " .. title .. "\n\n", - }) - require("heph.util").notify("created home page [[" .. title .. "]]") - end - require("heph.node").open(node.id) - return node -end - -return M diff --git a/heph.nvim/lua/heph/init.lua b/heph.nvim/lua/heph/init.lua deleted file mode 100644 index dd12fdc..0000000 --- a/heph.nvim/lua/heph/init.lua +++ /dev/null @@ -1,40 +0,0 @@ ---- 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. ---- ---- The plugin is **connect-only** — it never spawns or supervises a `hephd`. ---- Run the daemon as an OS service with `heph daemon start` ([[run-the-daemon]]); ---- this just connects to it. If nothing is serving the socket, we notify once ---- with guidance and let later calls retry (a plain reconnect, never a spawn). -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 - - local rpc = require("heph.rpc") - rpc.setup(cfg.socket) - - -- A cheap liveness probe so a missing daemon is reported up front, not as a - -- cryptic error on the first command. - local ok = pcall(function() - rpc.call("health", {}) - end) - if not ok then - require("heph.util").notify("no hephd at " .. cfg.socket .. " — run `heph daemon start`", vim.log.levels.WARN) - 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 deleted file mode 100644 index 691ad5a..0000000 --- a/heph.nvim/lua/heph/journal.lua +++ /dev/null @@ -1,62 +0,0 @@ ---- 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 - -local function iso_to_time(iso) - local y, m, d = iso:match("(%d+)-(%d+)-(%d+)") - return os.time({ year = tonumber(y), month = tonumber(m), day = tonumber(d), hour = 12 }) -end - ---- Entries for the `n` most recent days ending at `today` (ISO; default today), ---- newest first. Each: `{ date, node|nil, exists }`. Journals are titled by ---- their ISO date, so each day resolves directly. -function M.recent_entries(n, today) - today = (today and #today > 0) and today or util.iso_today() - local t0 = iso_to_time(today) - local entries = {} - for i = 0, n - 1 do - local date = os.date("%Y-%m-%d", t0 - i * 86400) - local node = rpc.call("node.resolve", { title = date }) - entries[#entries + 1] = { date = date, node = node, exists = node ~= nil } - end - return entries -end - ---- Pick among recent journal days — existing days preview their content, new ---- days show `@create` — and open the chosen day's journal (creating if new). -function M.pick(opts) - opts = opts or {} - local days = opts.days or (require("heph").config or {}).journal_days or 7 - require("heph.picker").select(M.recent_entries(days), { - prompt = "heph journals", - format = function(e) - return e.exists and e.date or (e.date .. " @create") - end, - preview = function(e) - if e.exists and e.node and e.node.body and #e.node.body > 0 then - return vim.split(e.node.body, "\n", { plain = true }) - end - return { "@create — new journal for " .. e.date } - end, - }, function(e) - if e then - M.open(e.date) - end - end) -end - -return M diff --git a/heph.nvim/lua/heph/link.lua b/heph.nvim/lua/heph/link.lua deleted file mode 100644 index 3ae89e0..0000000 --- a/heph.nvim/lua/heph/link.lua +++ /dev/null @@ -1,227 +0,0 @@ ---- `[[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, **creating** the target ---- doc if it doesn't exist yet (the zettelkasten follow-or-create gesture). The ---- newly-created doc resolves the source's previously-unresolved wiki-link, so ---- re-saving the source materializes the backlink. -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 - node = rpc.call("node.create", { kind = "doc", title = target, body = "" }) - util.notify("created [[" .. target .. "]]") - -- Materialize the source's wiki-link to the new doc — it was unresolved when - -- the source was saved, so extraction skipped it (tech-spec §5). If the - -- source has unsaved edits, saving re-extracts and materializes it (and - -- persists the edits); otherwise add the link directly (a no-op re-save - -- wouldn't re-extract). - local src = vim.api.nvim_get_current_buf() - local src_id = vim.b[src].heph_node_id - if src_id then - if vim.bo[src].modified then - pcall(require("heph.node").write, src, vim.api.nvim_buf_get_name(src)) - else - pcall(rpc.call, "links.add", { src = src_id, dst = node.id, link_type = "wiki" }) - end - end - end - require("heph.node").open(node.id) -end - --- Insert the labelled form `[[id|Name]]` at the cursor (readable + conceal-ready; --- it collapses to the canonical bare `[[id]]` on save, §8.4). -local function put_link(id, title) - vim.api.nvim_put({ "[[" .. id .. "|" .. title .. "]]" }, "c", true, true) -end - --- Mint a doc named `title` and insert a link to it. -local function create_and_put(title) - if title and #title > 0 then - local node = rpc.call("node.create", { kind = "doc", title = title }) - put_link(node.id, node.title) - end -end - --- Telescope is available and not explicitly disabled (tests force ui.select). -local function use_telescope() - return not vim.g.heph_force_ui_select and pcall(require, "telescope") -end - --- A live, fuzzy-filtered Telescope picker over every node: type to narrow, --- <CR> inserts the highlighted node, <C-x> creates a doc named the current --- prompt text (so a miss flows straight into "make it"). Telescope only. --- The markdown body to preview for a picker entry: a doc/journal's own body, --- or — for a task (which has no body of its own) — its canonical-context doc. -local function preview_body(node) - local ok, body = pcall(function() - local id = node.id - if node.kind == "task" then - for _, l in ipairs(rpc.call("links.outgoing", { id = node.id }) or {}) do - if l.link_type == "canonical-context" then - id = l.dst_id - break - end - end - end - local fetched = rpc.call("node.get", { id = id }) - return (fetched and fetched.body) or "" - end) - return vim.split(ok and body or "", "\n", { plain = true }) -end - -local function telescope_insert() - local pickers = require("telescope.pickers") - local finders = require("telescope.finders") - local conf = require("telescope.config").values - local actions = require("telescope.actions") - local action_state = require("telescope.actions.state") - local previewers = require("telescope.previewers") - - -- First-class targets only (excludes tags + tasks' context/log docs, §8.4). - local nodes = rpc.call("node.linkable", vim.empty_dict()) or {} - pickers - .new({}, { - prompt_title = "Link to node (<C-x> = create from prompt)", - finder = finders.new_table({ - results = nodes, - entry_maker = function(n) - return { - value = n, - display = n.title .. " [" .. (n.kind or "node") .. "]", - ordinal = n.title, - } - end, - }), - sorter = conf.generic_sorter({}), - previewer = previewers.new_buffer_previewer({ - title = "Preview", - define_preview = function(self, entry) - vim.api.nvim_buf_set_lines(self.state.bufnr, 0, -1, false, preview_body(entry.value)) - vim.bo[self.state.bufnr].filetype = "markdown" - end, - }), - attach_mappings = function(bufnr, map) - actions.select_default:replace(function() - local entry = action_state.get_selected_entry() - actions.close(bufnr) - if entry then - put_link(entry.value.id, entry.value.title) - end - end) - local create = function() - local title = action_state.get_current_line() - actions.close(bufnr) - create_and_put(title) - end - map("i", "<C-x>", create) - map("n", "<C-x>", create) - return true - end, - }) - :find() -end - --- Fallback when Telescope isn't available: prompt a query, search, then pick --- from the results (or a "+ Create" entry). `vim.ui.select` can't filter live. -local function uiselect_insert() - vim.ui.input({ prompt = "Link to: " }, function(query) - if not query or query == "" then - return - end - local items = {} - for _, hit in ipairs(rpc.call("search", { query = query }) or {}) do - items[#items + 1] = hit - end - items[#items + 1] = { __create = true, title = query } - require("heph.picker").select(items, { - prompt = "Link to", - format = function(it) - if it.__create then - return "+ Create new doc: " .. it.title - end - return it.title .. " [" .. (it.kind or "node") .. "]" - end, - }, function(choice) - if not choice then - return - elseif choice.__create then - create_and_put(choice.title) - else - put_link(choice.id, choice.title) - end - end) - end) -end - ---- Insert a canonical `[[NODEID|Name]]` link at the cursor (§8.4) by picking a ---- node — live-filtered via Telescope when available, else a search-then-select ---- prompt. A node id is the only thing that ever enters a stored link. -function M.insert() - if use_telescope() then - telescope_insert() - else - uiselect_insert() - end -end - ---- Attach the buffer-local follow/insert keymaps and inline-`#hashtag` ---- highlighting (only on heph:// buffers). -function M.attach(buf) - vim.keymap.set("n", "<CR>", function() - M.follow() - end, { buffer = buf, desc = "heph: follow [[link]]" }) - -- Typing `[[` opens the node picker (Obsidian-style), inserting `[[NODEID]]`. - vim.keymap.set("i", "[[", function() - M.insert() - end, { buffer = buf, desc = "heph: insert [[link]]" }) - - -- Render inline #hashtags in italics so they stand out — matching the - -- save-time tag detection (whitespace-prefixed `#word`, never a `# heading`). - -- `default = true` leaves a user's own `HephHashtag` definition intact. - vim.api.nvim_set_hl(0, "HephHashtag", { italic = true, default = true }) - vim.api.nvim_buf_call(buf, function() - vim.cmd([[syntax match HephHashtag /\v%(^|\s)@<=#[0-9A-Za-z_-]+/ containedin=ALL]]) - end) -end - -return M diff --git a/heph.nvim/lua/heph/node.lua b/heph.nvim/lua/heph/node.lua deleted file mode 100644 index a986a13..0000000 --- a/heph.nvim/lua/heph/node.lua +++ /dev/null @@ -1,79 +0,0 @@ ---- 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` ---- (with the editable YAML **frontmatter** block prepended, §8.3); `:w` diffs ---- the frontmatter into structured RPCs, then saves the body via `node.update` ---- (the backend CRDT-diffs the whole body, and strips any frontmatter echoed ---- back — so sending the stripped body is correct and idempotent). - -local rpc = require("heph.rpc") -local util = require("heph.util") -local frontmatter = require("heph.frontmatter") - -local M = {} - --- buf -> the frontmatter table rendered on read, so `write` can diff against it. -M._canonical = {} - ---- `BufReadCmd` handler for `heph://node/<id>`: load frontmatter + body. -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, frontmatter = true }) - local text = (node and node.body) or "" - local fm = frontmatter.parse(text) - -- `plain` split keeps a trailing "" element for a trailing newline, so the - -- text round-trips exactly through `table.concat` on write. - vim.api.nvim_buf_set_lines(buf, 0, -1, false, vim.split(text, "\n", { plain = true })) - vim.b[buf].heph_node_id = id - vim.b[buf].heph_node_kind = (node and node.kind) or "doc" - M._canonical[buf] = fm or {} - 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) - require("heph.conceal").attach(buf) -end - ---- `BufWriteCmd` handler: route frontmatter edits to RPCs, then save the 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 text = table.concat(vim.api.nvim_buf_get_lines(buf, 0, -1, false), "\n") - local fm, body = frontmatter.parse(text) - - local params = { id = id } - if fm then - -- A frontmatter block is present: translate its edits to RPCs. (Absent - -- block ⇒ the user removed it; treat the whole buffer as body, touch no - -- metadata.) The backend also strips defensively, so `body` is what stores. - local canonical = M._canonical[buf] or {} - frontmatter.apply(id, canonical, fm, body) - if fm.title and fm.title ~= canonical.title then - params.title = fm.title - end - params.body = body - M._canonical[buf] = fm - else - params.body = text - end - - rpc.call("node.update", params) - 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/picker.lua b/heph.nvim/lua/heph/picker.lua deleted file mode 100644 index de477c9..0000000 --- a/heph.nvim/lua/heph/picker.lua +++ /dev/null @@ -1,66 +0,0 @@ ---- A single selection primitive. Uses built-in `vim.ui.select` so headless e2e ---- needs no plugins; auto-upgrades to Telescope when it is installed and not ---- explicitly disabled. Tests set `vim.g.heph_force_ui_select` and stub ---- `vim.ui.select`, so a picker never blocks in `--headless`. - -local M = {} - -local function telescope_available() - return pcall(require, "telescope") -end - ---- Select one of `items`. `opts.prompt`, `opts.format(item)->string`, and an ---- optional `opts.preview(item)->lines` (Telescope only; markdown-rendered). ---- `on_choice(item|nil, index|nil)` — nil when cancelled. -function M.select(items, opts, on_choice) - opts = opts or {} - if not vim.g.heph_force_ui_select and telescope_available() then - -- Telescope path: a thin wrapper so fuzzy UX is available when present. - local pickers = require("telescope.pickers") - local finders = require("telescope.finders") - local conf = require("telescope.config").values - local actions = require("telescope.actions") - local action_state = require("telescope.actions.state") - local previewer = nil - if opts.preview then - previewer = require("telescope.previewers").new_buffer_previewer({ - define_preview = function(self, entry) - vim.api.nvim_buf_set_lines(self.state.bufnr, 0, -1, false, opts.preview(entry.value) or {}) - vim.bo[self.state.bufnr].filetype = "markdown" - end, - }) - end - pickers - .new({}, { - prompt_title = opts.prompt or "heph", - finder = finders.new_table({ - results = items, - entry_maker = function(item) - local display = opts.format and opts.format(item) or tostring(item) - return { value = item, display = display, ordinal = display } - end, - }), - sorter = conf.generic_sorter({}), - previewer = previewer, - attach_mappings = function(bufnr) - actions.select_default:replace(function() - actions.close(bufnr) - local sel = action_state.get_selected_entry() - on_choice(sel and sel.value or nil) - end) - return true - end, - }) - :find() - return - end - - vim.ui.select(items, { - prompt = opts.prompt, - format_item = opts.format, - }, function(choice, idx) - on_choice(choice, idx) - end) -end - -return M diff --git a/heph.nvim/lua/heph/rpc.lua b/heph.nvim/lua/heph/rpc.lua deleted file mode 100644 index 64add69..0000000 --- a/heph.nvim/lua/heph/rpc.lua +++ /dev/null @@ -1,219 +0,0 @@ ---- 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 - -local function is_connection_error(msg) - msg = tostring(msg) - return msg:find("connect", 1, true) ~= nil - or msg:find("connection", 1, true) ~= nil - or msg:find("timeout", 1, true) ~= nil -end - ---- Blocking call on the default session. The plugin is connect-only: on a ---- dropped connection we drop the dead session and **reconnect once** (e.g. the ---- daemon was restarted via `heph daemon restart`) — we never spawn a daemon. -function M.call(method, params, opts) - local ok, result = pcall(M.session().call, M.session(), method, params, opts) - if ok then - return result - end - if is_connection_error(result) then - M.session():close() -- drop the dead connection so the retry reconnects - return M.session():call(method, params, opts) - end - error(result) -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/task.lua b/heph.nvim/lua/heph/task.lua deleted file mode 100644 index 5a4d005..0000000 --- a/heph.nvim/lua/heph/task.lua +++ /dev/null @@ -1,120 +0,0 @@ ---- Task actions (tech-spec §8): capture, attention, state, skip, log. Actions ---- that operate on "the current task" resolve it from the buffer — either the ---- buffer is a `task` node, or it is a task's canonical-context doc, in which ---- case we follow the `canonical-context` backlink to its owning task. - -local rpc = require("heph.rpc") -local util = require("heph.util") - -local M = {} - ---- Capture a committed task. `opts`: attention, do_date, late_on, recurrence, ---- project. Returns the created task. -function M.capture(title, opts) - opts = opts or {} - return rpc.call("task.create", { - title = title, - attention = opts.attention, - do_date = opts.do_date, - late_on = opts.late_on, - recurrence = opts.recurrence, - project_id = opts.project, - }) -end - ---- The committed task id associated with the current buffer, or nil. -function M.current_task_id() - local buf = vim.api.nvim_get_current_buf() - local id = vim.b[buf].heph_node_id - if not id then - return nil - end - if vim.b[buf].heph_node_kind == "task" then - return id - end - -- A canonical-context doc: its owning task is the src of the - -- canonical-context link pointing here. - for _, l in ipairs(rpc.call("links.backlinks", { id = id })) do - if l.link_type == "canonical-context" then - return l.src_id - end - end - return nil -end - -local function with_task(action, what) - local id = M.current_task_id() - if not id then - util.notify("no task associated with this buffer", vim.log.levels.WARN) - return nil - end - action(id) - if what then - util.notify(what) - end - return id -end - ---- Mark the current task done/dropped (recurring tasks roll forward on done). -function M.set_state_current(state) - return with_task(function(id) - rpc.call("task.set_state", { id = id, state = state }) - end, "task " .. state) -end - ---- Set the current task's attention. -function M.set_attention_current(attention) - return with_task(function(id) - rpc.call("task.set_attention", { id = id, attention = attention }) - end, "attention → " .. attention) -end - ---- Skip the current recurring task's occurrence (advance without logging). -function M.skip_current() - return with_task(function(id) - rpc.call("task.skip", { id = id }) - end, "occurrence skipped") -end - ---- Promote the context-item line under the cursor to a committed task; the ---- daemon rewrites that line into a `[[link]]` to the new task. `opts.attention` ---- optional. Returns the created task. -function M.promote_under_cursor(opts) - opts = opts or {} - local buf = vim.api.nvim_get_current_buf() - local container_id = vim.b[buf].heph_node_id - if not container_id then - util.notify("not in a heph node buffer", vim.log.levels.WARN) - return nil - end - -- Save first so the daemon's body matches what the user sees on screen. - if vim.bo[buf].modified then - require("heph.node").write(buf, vim.api.nvim_buf_get_name(buf)) - end - local item_ref = util.context_item_index_at_cursor(buf) - if not item_ref then - util.notify("cursor is not on a context item (- [ ])", vim.log.levels.WARN) - return nil - end - local task = rpc.call("task.promote", { - container_id = container_id, - item_ref = item_ref, - attention = opts.attention, - }) - require("heph.node").reload(container_id) -- pull the rewritten body - util.notify("promoted to a task") - return task -end - ---- Append a line to the current task's log (the resumption breadcrumb). -function M.log_append_current(text) - if not text or #text == 0 then - util.notify("nothing to log", vim.log.levels.WARN) - return nil - end - return with_task(function(id) - rpc.call("log.append", { task_id = id, text = text }) - end, "logged") -end - -return M diff --git a/heph.nvim/lua/heph/util.lua b/heph.nvim/lua/heph/util.lua deleted file mode 100644 index 5d925c0..0000000 --- a/heph.nvim/lua/heph/util.lua +++ /dev/null @@ -1,51 +0,0 @@ ---- 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 - ---- The 1-based index of the context item on the cursor's line among the ---- buffer's context items (document order, fenced code skipped) — the `item_ref` ---- for `task.promote`. Mirrors heph-core's `extract.rs` ordering. Returns nil if ---- the cursor line is not a `- [ ]` / `- [x]` item. -function M.context_item_index_at_cursor(buf) - local cur = vim.api.nvim_win_get_cursor(0)[1] - local lines = vim.api.nvim_buf_get_lines(buf, 0, cur, false) -- lines 1..cursor - local in_fence, count, last_is_item = false, 0, false - for _, line in ipairs(lines) do - if line:match("^%s*```") then - in_fence = not in_fence - last_is_item = false - elseif not in_fence and line:match("^%s*[-*+]%s+%[[ xX]%]") then - count = count + 1 - last_is_item = true - else - last_is_item = false - end - end - if last_is_item then - return count - end - return nil -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/lua/heph/view.lua b/heph.nvim/lua/heph/view.lua deleted file mode 100644 index 4ceabe2..0000000 --- a/heph.nvim/lua/heph/view.lua +++ /dev/null @@ -1,215 +0,0 @@ ---- Task list views (tech-spec §8): Tactical `next` (the "what is next?" ranking) ---- and Organizational `list` (the whole outstanding set). Both render the same ---- titled rows the daemon returns into a scratch buffer, and are interactive: ---- <CR> open the task's canonical-context doc ---- a add a new task (prompt title + attention) from the list ---- d mark the task under the cursor done ---- r refresh -local rpc = require("heph.rpc") - -local M = {} - --- buf -> { tasks = <RankedTask[]>, refresh = fn }; line N maps to tasks[N]. -M._views = {} - -local hint_ns = vim.api.nvim_create_namespace("heph_view_hint") -local HINT = " <CR> open a add d done r refresh" - -local ATTENTIONS = { "white", "orange", "red", "blue" } - --- Compact relative date for a do/late epoch-ms value (mirrors heph-tui's fmt): --- today / tomorrow / yesterday, MM-DD within the year, else YYYY-MM-DD. -local function fmt_date(ms) - local d = os.date("*t", math.floor(ms / 1000)) - local n = os.date("*t") - local d_noon = os.time({ year = d.year, month = d.month, day = d.day, hour = 12 }) - local n_noon = os.time({ year = n.year, month = n.month, day = n.day, hour = 12 }) - local days = math.floor((d_noon - n_noon) / 86400 + 0.5) - if days == 0 then - return "today" - elseif days == 1 then - return "tomorrow" - elseif days == -1 then - return "yesterday" - elseif d.year == n.year then - return string.format("%02d-%02d", d.month, d.day) - else - return string.format("%04d-%02d-%02d", d.year, d.month, d.day) - end -end - --- The right-side date chip: a late marker once past due, else the do-date. -local function date_chip(t) - if t.late_on and os.time() * 1000 > t.late_on then - return "late:" .. fmt_date(t.late_on) - elseif t.do_date then - return "do:" .. fmt_date(t.do_date) - end - return "" -end - -local function row(t) - local tag = t.attention and ("[" .. t.attention .. "]") or "[ ]" - local recur = t.recurrence and " ↻" or "" - local left = string.format("%s %s%s", tag, t.title, recur) - local chip = date_chip(t) - if chip ~= "" then - return string.format("%-50s %s", left, chip) - end - return left -end - -local function task_on_line(buf) - local view = M._views[buf] - if not view then - return nil - end - -- line 1 is the key-hint header; task rows start at line 2. - return view.tasks[vim.api.nvim_win_get_cursor(0)[1] - 1] -end - --- Find or create the named scratch buffer, fill it, and (re)bind its keymaps. --- `refresh` re-runs the query+render so actions can reflect their changes. -local function render(name, tasks, refresh) - local buf - for _, b in ipairs(vim.api.nvim_list_bufs()) do - if vim.api.nvim_buf_get_name(b) == name then - buf = b - break - end - end - if not buf then - buf = vim.api.nvim_create_buf(false, true) -- unlisted scratch - vim.api.nvim_buf_set_name(buf, name) - end - vim.bo[buf].buftype = "nofile" - vim.bo[buf].bufhidden = "hide" - vim.bo[buf].swapfile = false - - -- Line 1 is a dimmed key hint; task rows follow. - local lines = { HINT } - for _, t in ipairs(tasks) do - lines[#lines + 1] = row(t) - end - if #tasks == 0 then - lines[#lines + 1] = "(nothing here — press 'a' to add a task)" - end - vim.bo[buf].modifiable = true - vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) - vim.bo[buf].modifiable = false - - vim.api.nvim_buf_clear_namespace(buf, hint_ns, 0, -1) - vim.api.nvim_buf_set_extmark(buf, hint_ns, 0, 0, { - end_row = 0, - end_col = #HINT, - hl_group = "Comment", - }) - - M._views[buf] = { tasks = tasks, refresh = refresh } - local function map(lhs, fn, desc) - vim.keymap.set("n", lhs, fn, { buffer = buf, desc = desc }) - end - map("<CR>", function() - M.open_under_cursor(buf) - end, "heph: open task context") - map("a", function() - M.add_from(buf) - end, "heph: add a task") - map("d", function() - M.done_under_cursor(buf) - end, "heph: mark task done") - map("r", function() - M.refresh(buf) - end, "heph: refresh") - vim.api.nvim_set_current_buf(buf) - if #tasks > 0 then - pcall(vim.api.nvim_win_set_cursor, 0, { 2, 0 }) -- land on the first task row - end - return buf -end - ---- Open the canonical-context doc of the task on the cursor line. -function M.open_under_cursor(buf) - buf = buf or vim.api.nvim_get_current_buf() - local t = task_on_line(buf) - if t then - require("heph.node").open(t.canonical_context_id or t.node_id) - end -end - ---- Re-run the view's query and re-render in place. -function M.refresh(buf) - local view = M._views[buf or vim.api.nvim_get_current_buf()] - if view and view.refresh then - view.refresh() - end -end - ---- Add a task from the list: prompt a title, pick an attention, capture, refresh. -function M.add_from(buf) - vim.ui.input({ prompt = "New task: " }, function(title) - if not title or #title == 0 then - return - end - require("heph.picker").select(ATTENTIONS, { prompt = "attention for: " .. title }, function(attention) - require("heph.task").capture(title, { attention = attention }) - require("heph.util").notify("captured: " .. title) - M.refresh(buf) - end) - end) -end - ---- Mark the task on the cursor line done, then refresh. -function M.done_under_cursor(buf) - local t = task_on_line(buf) - if not t then - return - end - rpc.call("task.set_state", { id = t.node_id, state = "done" }) - require("heph.util").notify("done: " .. t.title) - M.refresh(buf) -end - ---- Tactical "what is next?" — render the ranking, return the rows. -function M.next(opts) - opts = opts or {} - local tasks = rpc.call("next", { scope = opts.scope, limit = opts.limit or 5 }) - render("heph://next", tasks, function() - M.next(opts) - end) - return tasks -end - ---- Organizational survey — render the outstanding set, return the rows. ---- `list` takes a ListFilter (tech-spec §8.2); an empty table is the whole ---- outstanding set. Legacy opts map onto the filter fields. -function M.list(opts) - opts = opts or {} - local filter = {} - if opts.scope then - filter.scope = { opts.scope } - end - if opts.attention then - filter.attention_in = { opts.attention } - end - if opts.include_blue == false then - filter.attention_not = { "blue" } - end - local tasks = rpc.call("list", filter) - render("heph://list", tasks, function() - M.list(opts) - end) - return tasks -end - ---- A built-in filter view (tech-spec §8.2) — render its rows like `list`. -function M.view(name, opts) - opts = opts or {} - local tasks = rpc.call("view", { name = name }) - render("heph://view/" .. name, tasks, function() - M.view(name, opts) - end) - return tasks -end - -return M diff --git a/heph.nvim/plugin/heph.lua b/heph.nvim/plugin/heph.lua deleted file mode 100644 index 54dd95c..0000000 --- a/heph.nvim/plugin/heph.lua +++ /dev/null @@ -1,55 +0,0 @@ ---- 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://node/*", - 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://node/*", - 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 and stop any daemon this nvim spawned, cleanly, on exit. -vim.api.nvim_create_autocmd("VimLeavePre", { - group = grp, - callback = function() - pcall(function() - require("heph.rpc").close() - end) - pcall(function() - require("heph.daemon").stop_spawned() - 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 deleted file mode 100644 index 04e6072..0000000 --- a/heph.nvim/tests/e2e/backlink_spec.lua +++ /dev/null @@ -1,32 +0,0 @@ --- 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/capture_spec.lua b/heph.nvim/tests/e2e/capture_spec.lua deleted file mode 100644 index 9a2b8f4..0000000 --- a/heph.nvim/tests/e2e/capture_spec.lua +++ /dev/null @@ -1,54 +0,0 @@ --- Workflow (a): capture a task -> it appears in :Heph next -> open its canonical --- context -> add a checklist item -> check it -> mark the task done. - -local h = require("e2e.helpers") - -describe("task capture to done", function() - local ctx - before_each(function() - ctx = h.start() - end) - after_each(function() - h.stop(ctx) - end) - - it("captures, surfaces in next, edits the context checklist, and marks done", function() - local task = require("heph.task").capture("Fix roof", { attention = "orange" }) - assert.is_truthy(task.node_id) - - -- Surfaces in the Tactical view. - local ranked = require("heph.view").next() - local viewbuf = vim.api.nvim_get_current_buf() - local present = false - for _, l in ipairs(vim.api.nvim_buf_get_lines(viewbuf, 0, -1, false)) do - if l:find("Fix roof", 1, true) then - present = true - end - end - assert.is_true(present, "task missing from :Heph next") - assert.are.equal(task.node_id, ranked[1].node_id) - assert.is_truthy(ranked[1].canonical_context_id) - - -- Jump to its canonical context from the view (line 1 is the hint header, - -- task rows start at line 2). - vim.api.nvim_win_set_cursor(0, { 2, 0 }) - require("heph.view").open_under_cursor() - local ctxbuf = vim.api.nvim_get_current_buf() - assert.are.equal("heph://node/" .. ranked[1].canonical_context_id, vim.api.nvim_buf_get_name(ctxbuf)) - - -- Add a checklist item and save. - vim.api.nvim_buf_set_lines(ctxbuf, 0, -1, false, { "- [ ] buy shingles" }) - h.save(ctxbuf) - local stored = ctx.q:call("node.get", { id = ranked[1].canonical_context_id }) - assert.are.equal("- [ ] buy shingles", stored.body) - - -- Check it off and save. - vim.api.nvim_buf_set_lines(ctxbuf, 0, -1, false, { "- [x] buy shingles" }) - h.save(ctxbuf) - - -- Mark the task done from its context buffer (resolves the owning task). - local done_id = require("heph.task").set_state_current("done") - assert.are.equal(task.node_id, done_id) - assert.are.equal("done", ctx.q:call("task.get", { id = task.node_id }).state) - end) -end) diff --git a/heph.nvim/tests/e2e/follow_link_spec.lua b/heph.nvim/tests/e2e/follow_link_spec.lua deleted file mode 100644 index 1e0b51b..0000000 --- a/heph.nvim/tests/e2e/follow_link_spec.lua +++ /dev/null @@ -1,78 +0,0 @@ --- 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 lnum, col = h.find(buf, "%[%[B") -- the body line, below the frontmatter - assert.is_truthy(lnum) - -- Put the cursor on the target inside the brackets. - vim.api.nvim_win_set_cursor(0, { lnum, col + 2 }) - - 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("creates the target doc when following an unresolved [[link]]", function() - local a = h.create_doc("Daily", "see [[New Topic]]") - local buf = h.open(a.id) - local lnum, col = h.find(buf, "%[%[New") - vim.api.nvim_win_set_cursor(0, { lnum, col + 2 }) -- inside [[New Topic]] - - require("heph.link").follow() - - -- A new doc titled "New Topic" was created and opened. - local created = ctx.q:call("node.resolve", { title = "New Topic" }) - assert.is_truthy(created, "expected the target doc to be created") - assert.are.equal("heph://node/" .. created.id, vim.api.nvim_buf_get_name(0)) - - -- ...and the source now backlinks it (the wiki-link materialized). - local linked = false - for _, l in ipairs(ctx.q:call("links.backlinks", { id = created.id })) do - if l.src_id == a.id and l.link_type == "wiki" then - linked = true - end - end - assert.is_true(linked, "expected the source to backlink the created doc") - end) - - it("creates + links from an unsaved [[link]] just typed into the buffer", function() - -- The real gesture: open a note, type a new [[link]], <CR> without :w. - local a = h.create_doc("Journalish", "") - local buf = h.open(a.id) - vim.api.nvim_buf_set_lines(buf, 0, -1, false, { "ref [[Fresh Note]]" }) - assert.is_true(vim.bo[buf].modified) - local at = vim.api.nvim_buf_get_lines(buf, 0, 1, false)[1]:find("%[%[Fresh") - vim.api.nvim_win_set_cursor(0, { 1, at + 1 }) - - require("heph.link").follow() - - local created = ctx.q:call("node.resolve", { title = "Fresh Note" }) - assert.is_truthy(created) - assert.are.equal("heph://node/" .. created.id, vim.api.nvim_buf_get_name(0)) - -- The source's pending edit was persisted and the backlink materialized. - assert.are.equal("ref [[Fresh Note]]", ctx.q:call("node.get", { id = a.id }).body) - local linked = false - for _, l in ipairs(ctx.q:call("links.backlinks", { id = created.id })) do - if l.src_id == a.id and l.link_type == "wiki" then - linked = true - end - end - assert.is_true(linked, "expected the source edit saved and backlink materialized") - end) -end) diff --git a/heph.nvim/tests/e2e/frontmatter_spec.lua b/heph.nvim/tests/e2e/frontmatter_spec.lua deleted file mode 100644 index c0317b4..0000000 --- a/heph.nvim/tests/e2e/frontmatter_spec.lua +++ /dev/null @@ -1,99 +0,0 @@ --- Frontmatter edit surface (tech-spec §8.3): a node buffer opens with an --- editable YAML block on top, and saving routes each changed field to the right --- structured RPC (rename / set_attention / set_schedule / set_project / --- tag.add). The body itself still round-trips through node.update. - -local h = require("e2e.helpers") - --- Replace `key: …` in the frontmatter `lines`, or insert it before the closing --- `---` fence when the key isn't present yet. -local function set_field(lines, key, value) - for i, line in ipairs(lines) do - if line:match("^" .. key .. ":") then - lines[i] = key .. ": " .. value - return - end - end - local fences = 0 - for i, line in ipairs(lines) do - if line == "---" then - fences = fences + 1 - if fences == 2 then - table.insert(lines, i, key .. ": " .. value) - return - end - end - end -end - -describe("frontmatter edit surface", function() - local ctx - before_each(function() - ctx = h.start() - end) - after_each(function() - h.stop(ctx) - end) - - it("renders an editable block atop a node body", function() - local doc = h.create_doc("Roof", "# Roof\n\nnotes") - local buf = h.open(doc.id) - local first = vim.api.nvim_buf_get_lines(buf, 0, 1, false)[1] - assert.are.equal("---", first, "buffer should open with a frontmatter fence") - assert.is_truthy(h.find(buf, "^title: Roof"), "title not in frontmatter") - assert.is_truthy(h.find(buf, "# Roof"), "body content missing below frontmatter") - -- Inline #hashtags are rendered italic for visibility. - assert.is_true(vim.api.nvim_get_hl(0, { name = "HephHashtag" }).italic, "hashtag hl not italic") - end) - - it("routes frontmatter edits to structured RPCs on save", function() - local proj = ctx.q:call("node.create", { kind = "project", title = "Camano" }) - local task = ctx.q:call("task.create", { title = "Fix roof", attention = "red" }) - local ctxid - for _, l in ipairs(ctx.q:call("links.outgoing", { id = task.node_id })) do - if l.link_type == "canonical-context" then - ctxid = l.dst_id - end - end - - local buf = h.open(ctxid) - local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) - set_field(lines, "title", "Fix the roof") -- rename the (doc) node - set_field(lines, "attention", "blue") -- → task.set_attention - set_field(lines, "tags", "[roofing]") -- → tag.add on the doc - set_field(lines, "project", "Camano") -- → task.set_project - set_field(lines, "do_date", "2026-06-10") -- → task.set_schedule - vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) - h.save(buf) - - -- The task picked up attention, project, and a do-date. - local t = ctx.q:call("task.get", { id = task.node_id }) - assert.are.equal("blue", t.attention) - assert.is_truthy(t.do_date, "do_date should be set") - local filed = false - for _, l in ipairs(ctx.q:call("links.outgoing", { id = task.node_id })) do - if l.link_type == "in-project" and l.dst_id == proj.id then - filed = true - end - end - assert.is_true(filed, "task should be filed under Camano") - - -- The opened node was renamed and tagged. - assert.are.equal("Fix the roof", ctx.q:call("node.get", { id = ctxid }).title) - local tags = ctx.q:call("tag.list", { node_id = ctxid }) - assert.are.equal(1, #tags) - assert.are.equal("roofing", tags[1]) - end) - - it("adds inline #hashtags from the body as tags on save", function() - local doc = h.create_doc("Notes", "# Notes") - local buf = h.open(doc.id) - -- Append a body line with an inline hashtag (a `# heading` must not match). - vim.api.nvim_buf_set_lines(buf, -1, -1, false, { "", "see #kitchen and # not-a-tag" }) - h.save(buf) - - local tags = ctx.q:call("tag.list", { node_id = doc.id }) - assert.are.equal(1, #tags, "exactly the one inline tag") - assert.are.equal("kitchen", tags[1]) - end) -end) diff --git a/heph.nvim/tests/e2e/helpers.lua b/heph.nvim/tests/e2e/helpers.lua deleted file mode 100644 index a3d8ceb..0000000 --- a/heph.nvim/tests/e2e/helpers.lua +++ /dev/null @@ -1,207 +0,0 @@ ---- 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 uv = vim.uv or vim.loop - -local M = {} -local counter = 0 - ---- Spawn a `local`-mode hephd against `db` listening on `socket` (test infra — ---- the plugin itself is connect-only; the daemon is normally an OS service). -local function spawn(opts) - local args = { "--mode", "local", "--db", opts.db, "--socket", opts.socket } - local handle, pid = uv.spawn(opts.bin, { - 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-test: failed to spawn hephd (bin=" .. tostring(opts.bin) .. ")") - end - return { handle = handle, pid = pid } -end - ---- Wait until `socket` exists and answers `health`. Plain Lua loop — never a ---- `vim.wait` predicate (the rpc round-trip uses `vim.wait`; nesting deadlocks). -local function wait_ready(socket, timeout) - timeout = timeout or 5000 - local deadline = uv.hrtime() + timeout * 1e6 - while uv.hrtime() < deadline do - if uv.fs_stat(socket) ~= nil then - local session = rpc.new_session(socket) - local ok = pcall(function() - session:call("health", vim.empty_dict(), { timeout = 200 }) - end) - session:close() - if ok then - return true - end - end - vim.wait(50) - end - return false, "daemon not ready at " .. socket -end - -M.wait_ready = wait_ready - -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 - ---- A fresh temp dir + short socket/db paths, WITHOUT spawning a daemon (for ---- tests of the no-daemon-running case). `rm` removes it. -function M.tmp() - local dir = unique_dir() - return { - dir = dir, - sock = dir .. "/s", - db = dir .. "/db", - rm = function() - pcall(function() - vim.fn.delete(dir, "rf") - end) - end, - } -end - ---- Start a daemon on explicit paths and bind the plugin's rpc to it. Returns a ---- `ctx` with `dir, sock, db, daemon, exited, q` (an isolated assert session). -function M.start_on(dir, sock, 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 = spawn({ - bin = bin, - db = db, - socket = sock, - on_exit = function() - exited.done = true - end, - }) - local ok, reason = 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 - ---- Start a fresh daemon on a new temp dir and bind the plugin's rpc to it. -function M.start() - local dir = unique_dir() - return M.start_on(dir, dir .. "/s", dir .. "/db") -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 - ---- The (1-based line, 0-based byte col) of the first match of Lua `pattern` in ---- `buf`, or nil. Lets specs target body content by what it says rather than an ---- absolute line number — node buffers now carry a frontmatter block on top ---- (tech-spec §8.3), so the body no longer starts at line 1. -function M.find(buf, pattern) - for i, line in ipairs(vim.api.nvim_buf_get_lines(buf, 0, -1, false)) do - local s = line:find(pattern) - if s then - return i, s - 1 - end - end -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/home_spec.lua b/heph.nvim/tests/e2e/home_spec.lua deleted file mode 100644 index 170a08f..0000000 --- a/heph.nvim/tests/e2e/home_spec.lua +++ /dev/null @@ -1,27 +0,0 @@ --- The home / index landing page: open-or-create by title, idempotent. - -local h = require("e2e.helpers") - -describe("home page", function() - local ctx - before_each(function() - ctx = h.start() - end) - after_each(function() - h.stop(ctx) - end) - - it("creates the home page on first open and reuses it after", function() - local n1 = require("heph.home").open("Home") - assert.is_truthy(n1.id) - assert.are.equal("heph://node/" .. n1.id, vim.api.nvim_buf_get_name(0)) - assert.are.equal("doc", n1.kind) - - -- Reopening returns the SAME doc (open-or-create is idempotent by title). - local n2 = require("heph.home").open("Home") - assert.are.equal(n1.id, n2.id) - - -- It's a real, resolvable wiki target, so other notes can [[Home]] into it. - assert.are.equal(n1.id, ctx.q:call("node.resolve", { title = "Home" }).id) - end) -end) diff --git a/heph.nvim/tests/e2e/journal_picker_spec.lua b/heph.nvim/tests/e2e/journal_picker_spec.lua deleted file mode 100644 index 4b171aa..0000000 --- a/heph.nvim/tests/e2e/journal_picker_spec.lua +++ /dev/null @@ -1,50 +0,0 @@ --- The recent-days journal picker (the `zkd`-style dailies picker). - -local h = require("e2e.helpers") - -describe("journal picker", function() - local ctx - before_each(function() - ctx = h.start() - end) - after_each(function() - h.stop(ctx) - end) - - it("lists recent days newest-first with @create state", function() - local entries = require("heph.journal").recent_entries(5, "2026-06-02") - assert.are.equal(5, #entries) - assert.are.equal("2026-06-02", entries[1].date) -- newest first - assert.are.equal("2026-05-29", entries[5].date) -- crosses the month boundary - for _, e in ipairs(entries) do - assert.is_false(e.exists) -- nothing created yet - end - - -- Create one day's journal; it now reports as existing (with its node). - ctx.q:call("journal.open_or_create", { date = "2026-05-31" }) - local found - for _, e in ipairs(require("heph.journal").recent_entries(5, "2026-06-02")) do - if e.date == "2026-05-31" then - found = e - end - end - assert.is_true(found.exists) - assert.is_truthy(found.node) - end) - - it("opens the picked day's journal", function() - vim.g.heph_force_ui_select = true - local orig, picked = vim.ui.select, nil - vim.ui.select = function(items, _opts, on_choice) - picked = items[1] -- choose the newest day - on_choice(items[1]) - end - require("heph.journal").pick() - vim.ui.select, vim.g.heph_force_ui_select = orig, nil - - assert.is_truthy(picked) - local buf = vim.api.nvim_get_current_buf() - assert.are.equal("journal", vim.b[buf].heph_node_kind) - assert.are.equal(picked.date, ctx.q:call("node.get", { id = vim.b[buf].heph_node_id }).title) - end) -end) diff --git a/heph.nvim/tests/e2e/journal_spec.lua b/heph.nvim/tests/e2e/journal_spec.lua deleted file mode 100644 index 337c57b..0000000 --- a/heph.nvim/tests/e2e/journal_spec.lua +++ /dev/null @@ -1,32 +0,0 @@ --- 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/link_insert_spec.lua b/heph.nvim/tests/e2e/link_insert_spec.lua deleted file mode 100644 index 7ad590f..0000000 --- a/heph.nvim/tests/e2e/link_insert_spec.lua +++ /dev/null @@ -1,95 +0,0 @@ --- Wiki-links by node id (§8.4): the `[[` picker searches nodes and inserts a --- canonical `[[NODEID]]` link; a "Create" entry mints a new doc. The inserted --- id resolves on follow and materializes as a `wiki` link on save. - -local h = require("e2e.helpers") - -describe("link insert picker", function() - local ctx - before_each(function() - ctx = h.start() - end) - after_each(function() - h.stop(ctx) - end) - - -- Drive `link.insert` with a stubbed query + choice. - local function with_picker(query, pick, fn) - vim.g.heph_force_ui_select = true - local oi, os = vim.ui.input, vim.ui.select - vim.ui.input = function(_o, cb) - cb(query) - end - vim.ui.select = function(items, _o, cb) - cb(pick(items)) - end - local ok, err = pcall(fn) - vim.ui.input, vim.ui.select, vim.g.heph_force_ui_select = oi, os, nil - assert.is_true(ok, tostring(err)) - end - - it("inserts a canonical [[NODEID]] for a searched node and materializes the link", function() - local target = h.create_doc("Roofing", "the roofing doc") - local src = h.create_doc("Daily", "") - local buf = h.open(src.id) - -- Put the cursor on an empty body line below the frontmatter. - vim.api.nvim_buf_set_lines(buf, -1, -1, false, { "" }) - vim.api.nvim_win_set_cursor(0, { vim.api.nvim_buf_line_count(buf), 0 }) - - with_picker("roofing", function(items) - return items[1] -- the search hit (FTS matches the "roofing" token) - end, function() - require("heph.link").insert() - end) - - -- The buffer now carries `[[<target id>]]`, and saving materializes the link. - assert.is_truthy(h.find(buf, "%[%[" .. target.id), "[[id]] not inserted") - h.save(buf) - local linked = false - for _, l in ipairs(ctx.q:call("links.backlinks", { id = target.id })) do - if l.src_id == src.id and l.link_type == "wiki" then - linked = true - end - end - assert.is_true(linked, "expected a wiki link from src to the picked node") - end) - - it("conceals a link's id, leaving the name as a styled label", function() - local target = h.create_doc("Roofing", "") - -- A bare canonical link in the source; node.get expands it to [[id|Roofing]]. - local src = h.create_doc("Daily", "see [[" .. target.id .. "]]") - local buf = h.open(src.id) - - -- The expanded link is visible in the buffer... - assert.is_truthy(h.find(buf, "%[%[" .. target.id .. "|Roofing%]%]"), "expanded link missing") - -- ...and conceal extmarks hide the `[[id|` prefix + `]]` suffix. - local ns = vim.api.nvim_get_namespaces()["heph_link_conceal"] - assert.is_truthy(ns, "conceal namespace not registered") - local marks = vim.api.nvim_buf_get_extmarks(buf, ns, 0, -1, { details = true }) - local conceals = 0 - for _, m in ipairs(marks) do - if m[4] and m[4].conceal == "" then - conceals = conceals + 1 - end - end - assert.is_true(conceals >= 2, "expected prefix+suffix conceal extmarks, got " .. conceals) - assert.are.equal(2, vim.wo.conceallevel, "conceallevel not set for the buffer's window") - end) - - it("creates a new doc when the Create entry is chosen", function() - local src = h.create_doc("Notes", "") - local buf = h.open(src.id) - vim.api.nvim_buf_set_lines(buf, -1, -1, false, { "" }) - vim.api.nvim_win_set_cursor(0, { vim.api.nvim_buf_line_count(buf), 0 }) - - with_picker("Brand New Topic", function(items) - return items[#items] -- the "+ Create" sentinel is last - end, function() - require("heph.link").insert() - end) - - local created = ctx.q:call("node.resolve", { title = "Brand New Topic" }) - assert.is_truthy(created, "create entry should mint the doc") - assert.is_truthy(h.find(buf, "%[%[" .. created.id), "[[new id]] not inserted") - end) -end) diff --git a/heph.nvim/tests/e2e/managed_daemon_spec.lua b/heph.nvim/tests/e2e/managed_daemon_spec.lua deleted file mode 100644 index 4e2c6a5..0000000 --- a/heph.nvim/tests/e2e/managed_daemon_spec.lua +++ /dev/null @@ -1,47 +0,0 @@ --- The plugin is connect-only (tech-spec §8, [[design]] §4): it never spawns a --- daemon — it connects to one run as an OS service (`heph daemon start`). These --- specs cover connecting to a running daemon, a clean failure when none is --- running, and reconnecting after the daemon is restarted. - -local h = require("e2e.helpers") - -describe("connect-only daemon", function() - it("connects to a running daemon and works", function() - local ctx = h.start() -- harness starts a real daemon; binds the plugin to it - require("heph").setup({ socket = ctx.sock, keymaps = false }) - assert.is_truthy(require("heph.rpc").call("health", {})) - h.stop(ctx) - end) - - it("fails cleanly when no daemon is running (never spawns one)", function() - local t = h.tmp() -- temp socket path with nothing serving it - require("heph.rpc").setup(t.sock) - -- A call must fail loudly (connection error), not hang or spawn a daemon. - local ok = pcall(require("heph.rpc").call, "health", {}) - assert.is_false(ok, "expected a connection failure with no daemon running") - pcall(function() - require("heph.rpc").close() - end) - t.rm() - end) - - it("reconnects after the daemon is restarted under it", function() - local ctx = h.start() - require("heph").setup({ socket = ctx.sock, keymaps = false }) - require("heph.rpc").call("health", {}) - - -- Kill the daemon, then start a fresh one on the SAME socket (as - -- `heph daemon restart` would). The next call should reconnect. - ctx.daemon.handle:kill("sigterm") - vim.wait(2000, function() - return ctx.exited.done - end, 20) - pcall(function() - vim.uv.fs_unlink(ctx.sock) - end) - - local ctx2 = h.start_on(ctx.dir, ctx.sock, ctx.db) - assert.is_truthy(require("heph.rpc").call("health", {})) - h.stop(ctx2) - end) -end) diff --git a/heph.nvim/tests/e2e/promote_spec.lua b/heph.nvim/tests/e2e/promote_spec.lua deleted file mode 100644 index 203075f..0000000 --- a/heph.nvim/tests/e2e/promote_spec.lua +++ /dev/null @@ -1,47 +0,0 @@ --- Workflow (f): promote a context-item line to a committed task. The daemon --- mints the task and rewrites the source line into a [[link]] to it. - -local h = require("e2e.helpers") - -describe("promote context item", function() - local ctx - before_each(function() - ctx = h.start() - end) - after_each(function() - h.stop(ctx) - end) - - it("mints a task, links the source line, and surfaces it in next", function() - local container = h.create_doc("Errands", "- [ ] call plumber") - local buf = h.open(container.id) - local lnum = h.find(buf, "call plumber") -- the context-item line, below frontmatter - vim.api.nvim_win_set_cursor(0, { lnum, 0 }) - - local task = require("heph.task").promote_under_cursor({ attention = "orange" }) - assert.is_truthy(task.node_id) - - -- The promoted task appears in the Tactical ranking. - local found = false - for _, t in ipairs(ctx.q:call("next", { limit = 10 })) do - if t.node_id == task.node_id then - found = true - end - end - assert.is_true(found, "promoted task missing from next") - - -- The source line became a wiki-link to the task (persisted + in-buffer). - assert.are.equal("- [[call plumber]]", ctx.q:call("node.get", { id = container.id }).body) - local reloaded = vim.api.nvim_get_current_buf() - assert.is_truthy(h.find(reloaded, "%- %[%[call plumber%]%]"), "rewritten link line missing from buffer") - - -- ...and the container backlinks the task. - local linked = false - for _, l in ipairs(ctx.q:call("links.backlinks", { id = task.node_id })) do - if l.src_id == container.id and l.link_type == "wiki" then - linked = true - end - end - assert.is_true(linked, "expected a wiki backlink from the container to the task") - end) -end) diff --git a/heph.nvim/tests/e2e/recurring_spec.lua b/heph.nvim/tests/e2e/recurring_spec.lua deleted file mode 100644 index eb9a069..0000000 --- a/heph.nvim/tests/e2e/recurring_spec.lua +++ /dev/null @@ -1,54 +0,0 @@ --- Workflow (e): a recurring task with a checklist. Completing it must roll the --- task forward in place and present the next occurrence with a FRESH, --- all-unchecked checklist — completion never carries forward (tech-spec §4.4). --- --- The e2e daemon uses the real system clock (it's the actual binary), so we --- assert the do-date *advanced* past the original rather than an exact value; --- the fresh checklist is the hard requirement. - -local h = require("e2e.helpers") - -describe("recurring task checklist", function() - local ctx - before_each(function() - ctx = h.start() - end) - after_each(function() - h.stop(ctx) - end) - - it("resets the checklist to all-unchecked on the next occurrence", function() - local base = 1717200000000 -- 2024-06-01, safely in the past - local task = require("heph.task").capture("Water plants", { - recurrence = "FREQ=DAILY", - do_date = base, - }) - - -- Its canonical-context doc holds the checklist (the recurrence template). - local ctx_id - for _, l in ipairs(ctx.q:call("links.outgoing", { id = task.node_id })) do - if l.link_type == "canonical-context" then - ctx_id = l.dst_id - end - end - assert.is_truthy(ctx_id) - - -- Add a checklist via the buffer, then check both items off. - local buf = h.open(ctx_id) - h.set_lines(buf, { "- [ ] water fern", "- [ ] water cactus" }) - h.save(buf) - h.set_lines(buf, { "- [x] water fern", "- [x] water cactus" }) - h.save(buf) - - -- Complete the occurrence from its context buffer → rolls forward in place. - require("heph.task").set_state_current("done") - - local t = ctx.q:call("task.get", { id = task.node_id }) - assert.are.equal("outstanding", t.state) -- rolled forward, not done - assert.is_true(t.do_date > base, "do-date should advance to the next occurrence") - - -- The hard requirement: a fresh, all-unchecked checklist. - local stored = ctx.q:call("node.get", { id = ctx_id }) - assert.are.equal("- [ ] water fern\n- [ ] water cactus", stored.body) - end) -end) diff --git a/heph.nvim/tests/e2e/run.lua b/heph.nvim/tests/e2e/run.lua deleted file mode 100644 index d3d0a6e..0000000 --- a/heph.nvim/tests/e2e/run.lua +++ /dev/null @@ -1,30 +0,0 @@ ---- 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) - --- Guard against a false green: zero specs found (e.g. a path/glob mistake in a --- container) must fail, not pass silently. -if #files == 0 then - io.stderr:write("heph e2e: no *_spec.lua found under " .. e2e .. "\n") - vim.cmd("cquit 1") - return -end - -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 deleted file mode 100644 index bc1d124..0000000 --- a/heph.nvim/tests/e2e/runner.lua +++ /dev/null @@ -1,140 +0,0 @@ ---- 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 diff --git a/heph.nvim/tests/e2e/view_actions_spec.lua b/heph.nvim/tests/e2e/view_actions_spec.lua deleted file mode 100644 index 110c57a..0000000 --- a/heph.nvim/tests/e2e/view_actions_spec.lua +++ /dev/null @@ -1,60 +0,0 @@ --- Interactive task-list actions: add a task from the list, mark done from it. - -local h = require("e2e.helpers") - -describe("task list actions", function() - local ctx - before_each(function() - ctx = h.start() - end) - after_each(function() - h.stop(ctx) - end) - - it("adds a task from the list buffer", function() - require("heph.view").list() - local buf = vim.api.nvim_get_current_buf() - - vim.g.heph_force_ui_select = true - local orig_input, orig_select = vim.ui.input, vim.ui.select - vim.ui.input = function(_o, cb) - cb("Buy milk") - end - vim.ui.select = function(_items, _o, cb) - cb("orange") - end - require("heph.view").add_from(buf) - vim.ui.input, vim.ui.select, vim.g.heph_force_ui_select = orig_input, orig_select, nil - - -- The task was created with the chosen attention... - local found - for _, t in ipairs(ctx.q:call("list", {})) do - if t.title == "Buy milk" then - found = t - end - end - assert.is_truthy(found, "task not created") - assert.are.equal("orange", found.attention) - - -- ...and the list refreshed to show it. - local present = false - for _, l in ipairs(vim.api.nvim_buf_get_lines(vim.api.nvim_get_current_buf(), 0, -1, false)) do - if l:find("Buy milk", 1, true) then - present = true - end - end - assert.is_true(present, "added task missing from the refreshed list") - end) - - it("marks the task under the cursor done from the list buffer", function() - local t = ctx.q:call("task.create", { title = "Ship it", attention = "red" }) - require("heph.view").list() - local buf = vim.api.nvim_get_current_buf() - vim.api.nvim_win_set_cursor(0, { 2, 0 }) -- line 1 is the hint header - - require("heph.view").done_under_cursor(buf) - - assert.are.equal("done", ctx.q:call("task.get", { id = t.node_id }).state) - assert.are.equal(0, #ctx.q:call("list", {})) -- gone from the refreshed list - end) -end) diff --git a/heph.nvim/tests/e2e/view_spec.lua b/heph.nvim/tests/e2e/view_spec.lua deleted file mode 100644 index 7bc12e1..0000000 --- a/heph.nvim/tests/e2e/view_spec.lua +++ /dev/null @@ -1,57 +0,0 @@ --- Filter views (tech-spec §8.2): `:Heph view <name>` renders a built-in slice. - -local h = require("e2e.helpers") - -describe("filter views", function() - local ctx - before_each(function() - ctx = h.start() - end) - after_each(function() - h.stop(ctx) - end) - - it("renders the Top of Mind view (red|orange, not blue)", function() - ctx.q:call("task.create", { title = "urgent thing", attention = "red" }) - ctx.q:call("task.create", { title = "warm thing", attention = "orange" }) - ctx.q:call("task.create", { title = "cool thing", attention = "blue" }) - - -- The backend view returns just the red + orange tasks. - local rows = ctx.q:call("view", { name = "tom" }) - assert.are.equal(2, #rows) - - -- The plugin renders them into a dedicated view buffer. - require("heph.view").view("tom") - local buf = vim.api.nvim_get_current_buf() - assert.is_truthy( - vim.api.nvim_buf_get_name(buf):find("heph://view/tom", 1, true), - "view buffer not named heph://view/tom" - ) - local text = table.concat(vim.api.nvim_buf_get_lines(buf, 0, -1, false), "\n") - assert.is_truthy(text:find("urgent thing", 1, true), "red task missing from ToM") - assert.is_truthy(text:find("warm thing", 1, true), "orange task missing from ToM") - assert.is_falsy(text:find("cool thing", 1, true), "blue task should not be in ToM") - end) - - it("shows a do-date chip on a dated task row", function() - -- A past do-date keeps the task actionable (so it appears in ToM) and, with - -- no late_on, renders as a `do:` chip rather than a `late:` one. - ctx.q:call("task.create", { title = "dated thing", attention = "red", do_date = 1704067200000 }) - - require("heph.view").view("tom") - local buf = vim.api.nvim_get_current_buf() - local text = table.concat(vim.api.nvim_buf_get_lines(buf, 0, -1, false), "\n") - assert.is_truthy(text:find("dated thing", 1, true), "dated task missing") - assert.is_truthy(text:find("do:", 1, true), "do-date chip missing from the row") - end) - - it("scopes the chores view to chore projects via the daemon", function() - local chores = ctx.q:call("node.create", { kind = "project", title = "Chores" }) - ctx.q:call("task.create", { title = "take out trash", attention = "white", project_id = chores.id }) - ctx.q:call("task.create", { title = "unrelated", attention = "white" }) - - local rows = ctx.q:call("view", { name = "chores" }) - assert.are.equal(1, #rows) - assert.are.equal("take out trash", rows[1].title) - end) -end) diff --git a/mise-tasks/test-nvim b/mise-tasks/test-nvim deleted file mode 100755 index f0a1047..0000000 --- a/mise-tasks/test-nvim +++ /dev/null @@ -1,25 +0,0 @@ -#!/usr/bin/env bash -#MISE description="Run the heph.nvim headless e2e suite (builds hephd, drives nvim --headless)" - -# Dev path: uses system-installed nvim + rustc. CI runs the same suite inside a -# Dagger container that provides them (tech-spec §9, slice 11c). The runner is -# self-contained (tests/e2e/runner.lua) — no external nvim plugins, no network. - -set -euo pipefail - -ROOT="$(cd "$(dirname "$0")/.." && pwd)" -cd "$ROOT" - -if ! command -v nvim >/dev/null 2>&1; then - echo "error: nvim not found on PATH (install Neovim >= 0.10)" >&2 - exit 1 -fi - -echo "== building hephd (debug) ==" -cargo build -p hephd - -export HEPHD_BIN="$ROOT/target/debug/hephd" - -echo "== heph.nvim headless e2e ==" -cd "$ROOT/heph.nvim" -nvim --headless -u NONE -c "luafile tests/e2e/run.lua" diff --git a/prek.toml b/prek.toml index ebd8d1a..82ff957 100644 --- a/prek.toml +++ b/prek.toml @@ -76,13 +76,6 @@ repo = "https://github.com/rbubley/mirrors-prettier" rev = "v3.8.1" hooks = [{ id = "prettier", types_or = ["json"], args = ["--tab-width", "2"] }] -# Lua formatting (heph.nvim) - stylua, configured by .stylua.toml. Uses the -# system binary (installed locally; CI runs Rust/nvim suites via Dagger, not prek). -[[repos]] -repo = "https://github.com/JohnnyMorganz/StyLua" -rev = "v2.4.1" -hooks = [{ id = "stylua-system" }] - # Rust formatting - cargo fmt over the whole workspace, in place (like the other # formatters above). Uses the system toolchain; CI also enforces it via # `dagger call check` (cargo fmt --check). Runs whenever a .rs file is staged.