generated from eblume/project-template
infra: extract heph.nvim into its own forge repo
Some checks failed
Build / validate (pull_request) Failing after 1m12s
Some checks failed
Build / validate (pull_request) Failing after 1m12s
The Neovim plugin now lives at eblume/hephaestus.nvim (plugin at the repo root). Remove heph.nvim/ from the monorepo and the build/test wiring that referenced it: - Dagger: drop the test_nvim function + the pinned-Neovim NVIM_VERSION - build.yaml: drop the `dagger call test-nvim` step - drop the mise run test-nvim task and .stylua.toml + the stylua prek hook (no Lua remains in the monorepo) - install-heph.md: install via a plain lazy.nvim spec pointing at the plugin repo over SSH (no more local-dir checkout hack) - README / AGENTS / heph-nvim.md: note the surface lives in its own repo The CLI/TUI -> nvim integration is unchanged (they shell out to `nvim` expecting the heph plugin installed). The v1-prototype tech-spec §14 build record and prior changelog fragments are left as frozen history. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
6ddc9b83bf
commit
d36ed18590
42 changed files with 31 additions and 2965 deletions
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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=.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
1
docs/changelog.d/extract-heph-nvim.infra.md
Normal file
1
docs/changelog.d/extract-heph-nvim.infra.md
Normal file
|
|
@ -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]].
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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/<id>`. Opening it loads the markdown body via `node.get`; `:w`
|
||||
saves the whole buffer back via `node.update` (the backend diffs it into a
|
||||
text CRDT, so sending the full buffer is correct). `buftype=acwrite`.
|
||||
- **Links.** Press `<CR>` on a `[[wiki-link]]` to jump to its node (resolved
|
||||
exactly via `node.resolve`). Unresolved links are allowed — they just notify.
|
||||
- **Journal.** `:Heph today` (or `:Heph journal YYYY-MM-DD`) opens a dated
|
||||
journal note; the id is deterministic so reopening is idempotent.
|
||||
|
||||
## Setup
|
||||
|
||||
Requires 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, -- <leader>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 <YYYY-MM-DD>` | Open a dated journal |
|
||||
| `:Heph follow` | Follow the `[[link]]` under the cursor (also `<CR>`) |
|
||||
| `:Heph open <id>` | Open a node buffer by id |
|
||||
|
||||
## Tests
|
||||
|
||||
The e2e suite drives the plugin in headless Neovim against a real daemon:
|
||||
|
||||
```bash
|
||||
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).
|
||||
|
|
@ -1,155 +0,0 @@
|
|||
--- The `:Heph <subcommand>` user-command surface. Tactical/Organizational task
|
||||
--- views (next/list/capture/...) arrive with slice 11b; this is the knowledge-
|
||||
--- base core (journal, links).
|
||||
|
||||
local M = {}
|
||||
|
||||
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 <title>", 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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
})
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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")
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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)
|
||||
|
|
@ -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"
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue