35 Commits

Author SHA1 Message Date
gitea-actions 0df72e52ea chore(release): 0.4.2
CI / cargo fmt --check (push) Successful in 50s
CI / cargo clippy (push) Successful in 50s
CI / cargo test (push) Successful in 1m7s
CI / editor keymaps (push) Successful in 1m11s
2026-06-24 01:16:07 +00:00
gffranco 4a717bcb31 fix(parser): accept spaceless headings (==Heading==) like vimwiki (#7)
CI / cargo clippy (push) Successful in 34s
CI / cargo fmt --check (push) Successful in 39s
CI / cargo test (push) Successful in 1m4s
CI / editor keymaps (push) Successful in 1m12s
The heading lexer required a space after the opening `=`s, so `==Heading==`
fell through to a literal `<p>==Heading==</p>` instead of an `<h2>`. vimwiki
accepts the spaceless form, so a migrated page whose top heading was
`==TODO==` (or `====Progress====`) rendered with no heading at all.

Drop the space-after-marker requirement; the existing title trim already
handles one optional space per side, so both `== H ==` and `==H==` work.
Marker-only (`======`) and unbalanced (`==a==b`, `==> x`) lines still stay
paragraphs (title-present + matching trailing-`=` checks). Regression tests
added at the lexer and renderer levels.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 01:09:46 +00:00
gitea-actions f1494dd2fc chore(release): 0.4.1
CI / cargo clippy (push) Successful in 50s
CI / cargo fmt --check (push) Successful in 55s
CI / cargo test (push) Successful in 1m4s
CI / editor keymaps (push) Successful in 1m40s
2026-06-24 00:10:33 +00:00
gffranco a653903dba fix(html): keyword-in-heading badge + brackets in wikilink descriptions
CI / cargo clippy (push) Successful in 38s
CI / cargo fmt --check (push) Successful in 40s
CI / cargo test (push) Successful in 1m0s
CI / editor keymaps (push) Successful in 1m30s
Two export bugs reported against v0.4.0:

1. A heading whose text is a keyword (`== TODO ==`) rendered as an inline
   `<span class="todo">` badge with an *empty* id, instead of a styled
   header. Root causes: `inline_text` dropped Keyword nodes (so the heading
   id/anchor came out empty, or `My TODO list` → `My  list`), and the
   keyword span was emitted inside the heading. Fix: `inline_text` now
   includes the keyword's literal text (via new `Keyword::label()`), and
   heading content renders keywords as plain text (flatten_keywords) so a
   keyword title looks like a header. Keyword badges still render in body
   text as before.

2. A `]` inside a wikilink description (e.g. `[[page|Task [B-1]]]`) closed
   the link at the first `]]`, truncating the description and leaking a
   stray `]`. The close-scan is now bracket-aware (find_wikilink_close):
   inner `[ ]` pairs are balanced so the link closes at the correct `]]`.

Added regression tests for both.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 23:59:35 +00:00
gffranco e0f806d307 style: rustfmt the issue #6 parity changes
CI / cargo fmt --check (push) Successful in 54s
CI / cargo test (push) Successful in 1m18s
CI / cargo clippy (push) Successful in 1m24s
CI / editor keymaps (push) Successful in 1m42s
`cargo fmt --check` (CI) flagged the hand-formatted destructure in the
lexer test and an over-long line in export.rs. No behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 15:30:04 +00:00
gitea-actions 5dd02e7b9e chore(release): 0.4.0
CI / cargo fmt --check (push) Failing after 39s
CI / cargo clippy (push) Successful in 47s
CI / cargo test (push) Successful in 57s
CI / editor keymaps (push) Successful in 1m48s
2026-06-23 14:10:16 +00:00
gffranco 8fdfa9e256 fix(html): match vimwiki export output for headings, code fences, tags, anchors (#6)
CI / cargo fmt --check (push) Failing after 42s
CI / cargo clippy (push) Successful in 1m6s
CI / cargo test (push) Successful in 1m14s
CI / editor keymaps (push) Successful in 1m31s
Exported HTML diverged from upstream vimwiki's :VimwikiAll2HTML in ways
that break custom templates' CSS/JS and deep-links. Bring the renderer to
parity on the structural differences reported in #6:

1. Headings — restore vimwiki's structure: a wrapping
   `<div id="{hierarchical}">` (parent anchors joined by `-`),
   `class="header"`, and an in-heading `<a href="#{hierarchical}">`
   self-link. The flat `id` stays on the heading element so intra-page
   TOC / `#anchor` links keep resolving. Ancestor path is tracked while
   walking top-level headings.

2. Code fences — emit vimwiki's `<pre {raw-attrs}>` (the verbatim text
   after `{{{`, e.g. `<pre python>`) instead of
   `<pre><code class="language-X">`, so highlighters wired for vimwiki
   output apply. The fence text is now preserved verbatim through the
   lexer/AST (PreformattedNode.attrs).

3. Inline tag ids — drop the `tag-` prefix (`id="wiki"`, not
   `id="tag-wiki"`). Also fixes a real inconsistency: the LSP validated
   `[[Page#wiki]]` as resolvable while the HTML emitted `id="tag-wiki"`,
   so the exported link was broken.

4. Same-page anchors — `[[#Section]]` resolves to `index.html#Section`
   (current page filename + fragment), matching vimwiki, rather than a
   bare `#Section`.

Also ship vimwiki's stock style.css verbatim as the default stylesheet
(was a ~7-line minimal one) so exports look identical out of the box and
the `.header`/`.tag`/`.toc`/`done*` classes are styled.

Centered headings and the `<div class="toc">` Contents wrapper keep their
existing form. Tests updated across core + lsp to the new output; added
coverage for hierarchical ids, raw fence attrs, bare tag ids, and the
same-page anchor filename.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 14:04:14 +00:00
gffranco 59494e9a86 ci(release): stamp the version in-pipeline from a dispatch input
CI / cargo fmt --check (push) Successful in 54s
CI / cargo clippy (push) Successful in 58s
CI / cargo test (push) Successful in 1m8s
CI / editor keymaps (push) Successful in 1m44s
Releasing no longer means hand-editing the version across four files. The
Release workflow is now workflow_dispatch with a `version` input:

  Actions → Run workflow → version: 0.4.0

A new `prepare` job validates the semver, runs scripts/set-version.sh to
stamp it into the workspace Cargo.toml, the two internal path-dep pins,
g:nuwiki_version, and the Cargo.lock entries for our crates; gates on
`cargo test --workspace`; then commits "chore(release): X.Y.Z" and pushes
the vX.Y.Z tag. The build matrix and release job run off that freshly
pushed tag (checkout ref = the new tag), so the tagged commit carries the
real version.

scripts/set-version.sh is the single source of truth for where the version
lives — run it locally with the same arg to bump by hand. It patches
Cargo.lock via awk so it needs no cargo/toolchain.

The push:tags trigger is removed (the pipeline now creates the tag itself,
so a tag-push trigger would double-fire).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 15:06:33 +00:00
gffranco e1d734a434 chore(release): 0.3.0
CI / cargo fmt --check (push) Successful in 43s
CI / cargo clippy (push) Successful in 48s
CI / cargo test (push) Successful in 1m26s
Release / build aarch64-unknown-linux-musl (push) Successful in 1m26s
Release / build x86_64-unknown-linux-gnu (push) Successful in 57s
Release / build aarch64-unknown-linux-gnu (push) Successful in 1m5s
Release / build x86_64-unknown-linux-musl (push) Successful in 1m8s
Release / gitea release (push) Successful in 26s
CI / editor keymaps (push) Successful in 1m51s
Bump workspace + internal dep pins + g:nuwiki_version to 0.3.0.

Since 0.2.0:
- feat(config): read g:vimwiki_list on Neovim too (lazy.nvim drop-in)
- feat(config): bridge setup({wikis}) to g:nuwiki_wikis for VimL plugins
- feat(calendar): diary into the current wiki, not always the first
- fix(lsp): resolve links from anywhere in the link, including the title
- fix(install): resolve release asset via Gitea API / direct URL; guard
  download_bin.vim against 'compatible' mode

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 14:35:09 +00:00
gffranco fcce5621a0 fix(lsp): resolve links from anywhere in the link, including the title
CI / cargo fmt --check (push) Successful in 1m1s
CI / cargo clippy (push) Successful in 1m6s
CI / cargo test (push) Successful in 1m16s
CI / editor keymaps (push) Successful in 1m41s
goto-definition (and references/hover) reported "No locations found" when
the cursor was on the *description* of a piped link — `[[target|Title]]`
or `[desc](url)` — and only worked over the target/location part.

find_in_inlines descended into the link's description and returned its
inner Text node, so the nav handlers saw a Text (not a WikiLink/
ExternalLink) and bailed. A link is an atomic unit for navigation: stop
descending into descriptions so a hit anywhere in the link returns the
link node itself. Adds a navigation regression test for cursor-on-title.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 14:29:41 +00:00
gffranco 3e8c534859 feat(calendar): diary into the current wiki, not always the first
CI / cargo fmt --check (push) Successful in 46s
CI / cargo clippy (push) Successful in 51s
CI / cargo test (push) Successful in 58s
CI / editor keymaps (push) Successful in 1m29s
The VimL calendar-vim callbacks (nuwiki#diary#calendar_action /
calendar_sign) hardcoded wiki 0, so opening the calendar while editing
ifood_wiki still created/marked diary entries under the first wiki. (The
LSP-driven diary commands already follow the current buffer via its URI;
this brings the calendar in line.)

Track the wiki of the active buffer (nuwiki#diary#track_wiki, driven by
FileType/BufEnter autocmds in plugin/nuwiki.vim) and resolve the calendar
diary path against it, falling back to the first wiki. Verified the
calendar marker/open switch between wikis as you move between their
buffers. test-calendar-vim gains 4 multi-wiki tracking checks (16).
All keymap/config/calendar harnesses green on both clients.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 02:07:11 +00:00
gffranco 0d1a73a3ba feat(config): bridge setup({wikis}) to g:nuwiki_wikis for VimL plugins
CI / cargo fmt --check (push) Failing after 11s
CI / cargo clippy (push) Successful in 32s
CI / cargo test (push) Successful in 57s
CI / editor keymaps (push) Successful in 1m33s
A Lua-only `setup({ wikis = … })` config is invisible to the VimL side:
the buffer helpers in autoload/nuwiki/config.vim and the vimwiki compat
shim (autoload/vimwiki/vars.vim, which third-party plugins like
vimwiki-sync call via vimwiki#vars#get_wikilocal) read g:nuwiki_wikis. So
a Neovim user who migrated from g:vimwiki_list to native setup({wikis})
would break vimwiki-sync (it'd resolve the default ~/vimwiki).

setup() now publishes the resolved wiki list to g:nuwiki_wikis when the
config came from setup() opts (the only case VimL can't already see —
g:nuwiki_wikis / g:vimwiki_list configs are global and visible). Verified
vimwiki#vars#get_wikilocal('path', n) returns the right roots from a
pure-Lua setup() config. test-global-shorthand grows two bridge checks
(17). Goldens + keymap harnesses green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 01:34:24 +00:00
gffranco e6f400b370 feat(config): read g:vimwiki_list on Neovim too (lazy.nvim drop-in)
CI / cargo fmt --check (push) Successful in 2m12s
CI / cargo clippy (push) Successful in 2m18s
CI / cargo test (push) Successful in 2m24s
CI / editor keymaps (push) Successful in 1m27s
The g:vimwiki_* drop-in was Vim-only: the Lua client read only setup()
opts and g:nuwiki_wikis, so swapping the vimwiki plugin for nuwiki under
lazy.nvim left the existing vim.g.vimwiki_list ignored and nuwiki pointed
at the default ~/vimwiki.

Mirror autoload/nuwiki/config.vim on the Lua side: config.lua now owns a
single resolver (M.wikis / M.scalar_globals) that resolves setup() wikis →
g:nuwiki_wikis → g:vimwiki_list (translating path->root, path_html->html_path,
template_*, ext->file_extension, …) and folds the global scalar defaults
(g:vimwiki_* then g:nuwiki_* then explicit setup() values; built-in defaults
excluded). lsp.lua (payload) and wiki_cfg/wiki_list (buffer commands) both
route through it, so the server and <Leader>ww agree on the wikis.

Extends test-global-shorthand (15 checks) with the vimwiki_list drop-in +
buffer-command resolution + native-wins cases. Config-parity goldens and
keymap harnesses green on both clients. README + known-issues updated
(drop-in is now Vim & Neovim).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 01:15:43 +00:00
gffranco 96d53dddf3 fix(install): guard download_bin.vim against 'compatible' mode (dein build)
CI / cargo fmt --check (push) Successful in 33s
CI / cargo clippy (push) Successful in 36s
CI / cargo test (push) Successful in 46s
CI / editor keymaps (push) Successful in 1m49s
The dein/vim-plug build hook runs `vim -e -s -c "source scripts/download_bin.vim"`,
which starts in 'compatible' mode. There, leading-backslash line
continuations aren't recognised, so the multi-line URL string raised
`E10: \ should be followed by /, ? or &`. The install actually succeeded
(binary downloaded), but in silent Ex mode any emitted error makes Vim
exit non-zero — so dein reported a build failure. :NuwikiInstall worked
because a normal session is 'nocompatible'.

Wrap the script in the standard `let s:cpo_save = &cpo | set cpo&vim` …
`let &cpo = s:cpo_save` guard so line continuations parse regardless of
how it's invoked. Verified the exact dein command now exits 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 02:05:56 +00:00
gffranco c144cbbb55 fix(install): simplify to the direct /releases/download/latest/ URL
CI / cargo fmt --check (push) Successful in 1m10s
CI / cargo clippy (push) Successful in 1m13s
CI / cargo test (push) Successful in 1m21s
CI / editor keymaps (push) Successful in 1m42s
Gitea serves the most recent release under the `latest` tag segment, so
`/releases/download/latest/nuwiki-ls-<target>.tar.gz` resolves directly
(HTTP 200) — no API lookup or JSON parsing needed. Drop the
latest_asset_url helpers added in the previous fix; the only real bug was
the URL shape (`/releases/latest/download/` is GitHub-only and 404s on
Gitea). Both install paths now just pick the target and build the URL.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 01:46:18 +00:00
gffranco 3c1ed48a2f fix(install): resolve release asset via Gitea API (download was always 404)
CI / cargo fmt --check (push) Successful in 29s
CI / cargo clippy (push) Successful in 40s
CI / cargo test (push) Successful in 46s
CI / editor keymaps (push) Successful in 1m40s
:NuwikiInstall always fell back to building from source because the
download URL used the GitHub-only shape
`/releases/latest/download/<asset>`, which this Gitea instance serves as
404. The release assets exist and are correctly named; only the URL was
wrong.

Resolve the asset's real download URL through the Gitea API instead: GET
`/api/v1/repos/.../releases/latest`, find the asset matching
`nuwiki-ls-<target>.tar.gz`, and download its `browser_download_url`
(`/releases/download/<tag>/<asset>`, which returns 200). Fixed in both
install paths — lua/nuwiki/install.lua (vim.json.decode) and
scripts/download_bin.vim (json_decode). Verified end to end: resolve →
download → extract → executable binary.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 01:41:21 +00:00
gffranco 34a0607e7a chore(release): bump version to 0.2.0
CI / cargo fmt --check (push) Successful in 48s
CI / cargo clippy (push) Successful in 1m0s
CI / cargo test (push) Successful in 1m28s
Release / build aarch64-unknown-linux-musl (push) Successful in 1m33s
Release / build x86_64-unknown-linux-gnu (push) Successful in 1m0s
Release / build aarch64-unknown-linux-gnu (push) Successful in 1m2s
Release / build x86_64-unknown-linux-musl (push) Successful in 1m1s
Release / gitea release (push) Successful in 26s
CI / editor keymaps (push) Successful in 1m45s
Minor feature release over v0.1.0 (124 commits). Highlights:
- Drop-in upstream g:vimwiki_list / g:vimwiki_* config (Vim) + global
  per-wiki defaults shorthand (both clients)
- Programmatic coc.nvim registration (no hand-written coc-settings.json)
- :NuwikiTOC / GenerateLinks / GenerateTagLinks insert at the cursor line;
  scoped :NuwikiGenerateLinks <path> wired up
- HTML export: heading id anchors, div.toc wrapper, section numbering;
  toc_header/level honoured in single-wiki config
- The full P3 config/command/mapping parity batch + R1–R18 review fixes

Bumps the workspace version, the inter-crate path-dep requirements, and
g:nuwiki_version.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 01:29:29 +00:00
gffranco daee0f1902 feat(links): wire up nuwiki.links.generateForPath (scoped GenerateLinks)
CI / cargo fmt --check (push) Successful in 24s
CI / cargo clippy (push) Successful in 30s
CI / cargo test (push) Successful in 40s
CI / editor keymaps (push) Successful in 1m22s
`:VimwikiGenerateLinks <path>` sent nuwiki.links.generateForPath, but the
server had no handler and didn't advertise it — so the scoped form was
silently dropped (only the no-arg form worked). Register and implement it.

The page list is scoped via page_in_scope: a plain `path` is a directory
subtree (the page itself or anything under `<path>/`); a `path` with `*`/`?`
is a glob pattern (upstream's behaviour), matched with export::glob_match
(now pub(crate)). links_generate and the scoped form share
links_generate_scoped, so cursor-line insertion + captions + config all
apply uniformly.

Tests: page_in_scope unit tests (subtree, glob, prefix-sibling, trailing
slash) + COMMANDS advertises both generate forms. 580 passed, clippy
clean, harnesses green. Doc updated with the [path] argument.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 01:23:42 +00:00
gffranco f0c51fbfcc feat(generate): insert links/tags sections at the cursor line too
CI / cargo fmt --check (push) Successful in 28s
CI / cargo clippy (push) Successful in 36s
CI / cargo test (push) Successful in 40s
CI / editor keymaps (push) Successful in 1m26s
Extend the cursor-line insertion from :NuwikiTOC to the other buffer
list generators: :NuwikiGenerateLinks and :NuwikiGenerateTagLinks. They
appended at end-of-file; a fresh section now goes at the cursor line
(with a leading blank), matching :NuwikiTOC. An existing section is still
refreshed in place, and the auto_generate_*-on-save hooks are unaffected
(they only ever replace).

Shared section_insert() helper computes the insert position/block for all
three (cursor line clamped to the document, else the per-command fallback:
top of file for TOC, EOF for links/tags). Clients send the 0-based cursor
line; handlers parse it via parse_uri_line_arg and thread it through
links_edit / tag_links_edit. Rebuild variants pass None.

Tests: links_edit_inserts_at_cursor_line + tag_links_edit_inserts_at_cursor_line.
577 passed, clippy clean, keymap harnesses green. Docs updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 01:07:18 +00:00
gffranco 94cb58064d feat(toc): insert a fresh TOC at the cursor line
CI / cargo fmt --check (push) Successful in 35s
CI / cargo clippy (push) Successful in 39s
CI / cargo test (push) Successful in 50s
CI / editor keymaps (push) Successful in 1m25s
:NuwikiTOC inserted a new table of contents at the top of the file. It now
inserts at the cursor line instead, so you can place the TOC where you want
it (a blank line separates it from preceding text). An existing TOC is
still refreshed in place, and auto_toc-on-save is unaffected.

The Vim/Lua clients send the 0-based cursor line with nuwiki.toc.generate;
the server threads it into toc_edit (clamped to the document) and falls
back to the top of the file when absent. toc_rebuild_edit passes None.

Tests: toc_edit_inserts_at_cursor_line +
toc_edit_cursor_line_clamped_and_existing_replaced_in_place. 575 passed,
clippy clean, keymap harnesses green. Doc updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 00:51:40 +00:00
gffranco 9d6d28a1b6 fix(vim): buffer commands honour g:vimwiki_list (<Leader>ww opened ~/vimwiki)
CI / cargo fmt --check (push) Successful in 33s
CI / cargo clippy (push) Successful in 33s
CI / cargo test (push) Successful in 41s
CI / editor keymaps (push) Successful in 1m23s
The g:vimwiki_* drop-in only fed the LSP payload (lsp.vim s:settings).
The buffer-side commands — <Leader>ww (wiki_index), :VimwikiSearch,
auto_chdir, diary, completion — resolve the wiki list independently via
s:wiki_cfg / nuwiki#commands#wiki_list, which read only g:nuwiki_wikis /
g:nuwiki_wiki_root. So with a g:vimwiki_list config the server knew the
wikis but <Leader>ww fell back to the default ~/vimwiki and opened an
empty index.

Extract the resolution into a single source of truth,
autoload/nuwiki/config.vim (nuwiki#config#wikis / #scalar_globals), which
resolves g:nuwiki_wikis or an upstream g:vimwiki_list and folds the global
scalar defaults. lsp.vim (payload), commands.vim, and diary.vim all route
through it — also dedups the s:wiki_cfg copy that lived in both
commands.vim and diary.vim.

Regression guard in test-vimwiki-compat-vim: nuwiki#commands#wiki_list()
must resolve the g:vimwiki_list roots (21 checks). All Vim harnesses +
config-parity goldens green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 00:40:48 +00:00
gffranco 346e8b0de6 dev: NUWIKI_DEV_VIMWIKI mode to dogfood the g:vimwiki_* drop-in
CI / cargo fmt --check (push) Successful in 25s
CI / cargo clippy (push) Successful in 30s
CI / cargo test (push) Successful in 38s
CI / editor keymaps (push) Successful in 1m31s
The start-vim-coc.sh harness runs `vim --clean`, so it never sources a
user's real vimwiki.vim — meaning g:vimwiki_list is never set and the
g:vimwiki_* drop-in translation was impossible to exercise through the
harness (you'd only ever test the native g:nuwiki_* path).

Add NUWIKI_DEV_VIMWIKI=1: instead of g:nuwiki_*, the generated vimrc
configures an upstream g:vimwiki_list (+ g:vimwiki_toc_header_level etc.)
pointing at the scratch wiki, so nuwiki's client-side translation +
programmatic coc registration run on a real vimwiki-shaped config.

    NUWIKI_DEV_VIMWIKI=1 ./development/start-vim-coc.sh

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 00:26:11 +00:00
gffranco ac14cdd838 feat(coc): auto-register the language server with coc.nvim
CI / cargo fmt --check (push) Successful in 47s
CI / cargo clippy (push) Successful in 46s
CI / cargo test (push) Successful in 56s
CI / editor keymaps (push) Successful in 1m30s
coc users had to hand-maintain a coc-settings.json `languageserver.nuwiki`
entry, which didn't track g:nuwiki_wikis / g:vimwiki_list / the global
shorthand and meant editing JSON per wiki. nuwiki now registers itself
programmatically: on the first .wiki buffer it calls
coc#config('languageserver.nuwiki', {…}) with the s:settings() payload
(same config the vim-lsp path sends). coc reacts to the languageserver
config change (onDidChangeConfiguration → registerClientsByConfig) and
starts the server for the open buffer.

Details:
- Reliable coc detection via :CocConfig (exists('*coc#config') can't be
  trusted — it doesn't trigger autoload in Vim 9.2). The coc#config call
  is wrapped in try/catch and autoloads coc.vim itself; falls back to the
  printed snippet only if coc genuinely isn't there.
- Deferred to User CocNvimInit when coc's node service isn't up yet.
- Opt out with g:nuwiki_no_coc_register (then we just print the snippet).

Dogfooded in start-vim-coc.sh: dropped the manual coc-settings.json
languageserver block; the harness now relies on auto-registration from
g:nuwiki_* (real-user flow). New harness test-coc-register-vim (7 checks,
stubbed coc#config) asserts the injected payload incl. the folded global
shorthand; wired into CI. README coc section rewritten (zero-config).
Rust 573 passed, clippy clean, all harnesses green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 00:07:50 +00:00
gffranco a4643bdacb dev: exercise toc_header_level + html numbering in the dev launchers
CI / cargo fmt --check (push) Successful in 36s
CI / cargo clippy (push) Successful in 45s
CI / cargo test (push) Successful in 51s
CI / editor keymaps (push) Successful in 1m47s
The start-* dev harnesses generated a minimal server config (wiki_root,
file_extension, syntax, log_level, diagnostic) with no display settings,
so :NuwikiTOC always produced `= Contents =` (level 1) regardless of code
changes — there was no way to test toc_header_level/html numbering through
the harness. The coc launcher is worst: coc reads coc-settings.json
verbatim, bypassing the Vim client's config translation entirely.

Set toc_header_level=2, html_header_numbering=2, html_header_numbering_sym
in all three launchers:
- start-vim-coc.sh: into the generated coc-settings.json (init + settings).
- start-vim.sh: as g:nuwiki_* globals (folded into the wiki via the global
  shorthand).
- start-nvim.sh: in the setup() table.

Now the dev wiki demonstrates the settings: :NuwikiTOC writes
`== Contents ==` and HTML export numbers headings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 23:50:40 +00:00
gffranco 9d89e01ed8 feat(config): vimwiki drop-in config + global per-wiki defaults
CI / cargo fmt --check (push) Successful in 31s
CI / cargo clippy (push) Successful in 32s
CI / cargo test (push) Successful in 41s
CI / editor keymaps (push) Successful in 1m53s
Two related config-ergonomics features for vimwiki migrants, sharing the
same scalar-global machinery.

1. Upstream g:vimwiki_* drop-in (Vim client). When nuwiki isn't configured
   natively, the Vim client reads g:vimwiki_list + g:vimwiki_* globals and
   translates them into nuwiki's schema: per-wiki path->root,
   path_html->html_path, template_*, css_name, auto_export/auto_toc,
   syntax, ext->file_extension, index, diary_*, name. g:nuwiki_* always
   wins. Lets a vimwiki user drop in nuwiki without rewriting config.

2. Global per-wiki defaults (both clients). A display/generation setting
   given once at the top level — g:nuwiki_<key> / setup({<key>=…}) /
   g:vimwiki_<key> — is folded into every wiki as a default; a per-wiki
   value overrides it (vimwiki's model). Covers toc_header(_level),
   toc_link_format, links_header(_level), tags_header(_level),
   html_header_numbering(_sym), links_space_char, list_margin, listsyms,
   listsym_rejected, and the auto_* toggles. Only values the user
   explicitly set fold — built-in defaults don't (added config.user
   tracking on the Lua side so a default toc_header_level=1 isn't pushed
   onto every wiki). User config tables are never mutated.

Both clients kept in lock-step (config-parity golden enforces identical
payloads). New harnesses test-vimwiki-compat-vim (18) and
test-global-shorthand (8), wired into CI. Server test
vimwiki_compat_payload_sets_toc_level_per_wiki. Docs in README +
known-issues.md (drop-in is currently Vim-only). Rust 573 passed, clippy
clean, all harnesses green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 23:37:10 +00:00
gffranco fc0d74bfbe fix(toc): honour toc_header/level in single-wiki config + div.toc on export
CI / cargo fmt --check (push) Successful in 48s
CI / cargo clippy (push) Successful in 54s
CI / cargo test (push) Successful in 1m4s
CI / editor keymaps (push) Successful in 1m38s
Two TOC bugs:

1. :NuwikiTOC ignored toc_header / toc_header_level (always "= Contents ="
   at level 1). In the single-wiki shorthand the server only carried
   file_extension/syntax from the top-level options into the synthesized
   wiki; every other per-wiki key (toc_header, toc_header_level,
   links_header, html_path, auto_export, …) was dropped. Add
   single_wiki_from_value(), which re-parses the top-level object as a full
   RawWiki (injecting root from wiki_root), so all per-wiki keys set at the
   top level flow through. Wired into from_init_params + apply_change.

2. HTML export put class="toc" on the <hN> element, but upstream vimwiki
   (and its stylesheet) wraps the TOC heading in <div class="toc">…</div>.
   render_heading now emits the div wrapper, matching upstream so .toc CSS
   applies. With (1) fixed, detection also works for a custom toc_header.

Tests: single_wiki_shorthand_honours_top_level_per_wiki_keys (config),
toc_header_heading_wrapped_in_div_toc + non_toc_heading_not_wrapped
(renderer). Full suite 572 passed; clippy clean; config-parity goldens
match.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 22:56:17 +00:00
gffranco f3d8af5f23 fix: TOC header class, numbering config, and Lua defaults
CI / cargo fmt --check (push) Successful in 31s
CI / cargo clippy (push) Successful in 31s
CI / cargo test (push) Successful in 36s
Release / build x86_64-unknown-linux-gnu (push) Successful in 53s
Release / build aarch64-unknown-linux-musl (push) Successful in 1m6s
Release / build x86_64-unknown-linux-musl (push) Successful in 1m8s
Release / build aarch64-unknown-linux-gnu (push) Successful in 1m47s
Release / gitea release (push) Successful in 28s
CI / editor keymaps (push) Successful in 1m39s
- Add class='toc' to TOC heading in HTML export by detecting the
  configured toc_header value in render_heading()
- Add toc_header field to HtmlRenderer and wire it through
  render_page_html()
- Add toc_header, toc_header_level, html_header_numbering, and
  html_header_numbering_sym to Lua config defaults so users can
  discover and set them
2026-06-04 00:39:20 -03:00
gffranco bd729d513d fix: include release notes in Gitea API payload
CI / cargo fmt --check (push) Successful in 29s
CI / cargo clippy (push) Successful in 35s
CI / cargo test (push) Successful in 52s
Release / build x86_64-unknown-linux-gnu (push) Successful in 50s
Release / build x86_64-unknown-linux-musl (push) Successful in 56s
CI / editor keymaps (push) Successful in 1m55s
Release / build aarch64-unknown-linux-gnu (push) Successful in 3m16s
Release / build aarch64-unknown-linux-musl (push) Successful in 4m23s
Release / gitea release (push) Successful in 1m40s
The release notes were generated but never passed to the Gitea API.
Also suppress the expected 404 from the idempotency check.
2026-06-03 22:43:53 -03:00
gffranco 5a936e4f96 fix: add checkout step to release job for git commands
CI / cargo fmt --check (push) Successful in 49s
CI / cargo clippy (push) Successful in 49s
CI / cargo test (push) Successful in 58s
Release / build aarch64-unknown-linux-musl (push) Successful in 1m2s
Release / build x86_64-unknown-linux-gnu (push) Successful in 1m36s
Release / build x86_64-unknown-linux-musl (push) Successful in 1m34s
CI / editor keymaps (push) Successful in 1m44s
Release / build aarch64-unknown-linux-gnu (push) Successful in 2m27s
Release / gitea release (push) Successful in 1m51s
download-artifact@v3 doesn't create a git repo, so git log/git tag
fail in the release notes generation step.
2026-06-03 22:33:08 -03:00
gffranco 32cf4508de fix: use artifact v3 for GHES compatibility
CI / cargo fmt --check (push) Successful in 38s
CI / cargo clippy (push) Successful in 34s
CI / cargo test (push) Successful in 32s
Release / build aarch64-unknown-linux-musl (push) Successful in 3m31s
Release / build x86_64-unknown-linux-gnu (push) Successful in 57s
Release / build aarch64-unknown-linux-gnu (push) Successful in 1m8s
Release / build x86_64-unknown-linux-musl (push) Successful in 1m3s
CI / editor keymaps (push) Successful in 1m22s
Release / gitea release (push) Failing after 14s
Gitea (GHES) does not support upload-artifact@v4+.
Revert to v3 which is supported on GHES.
2026-06-03 22:17:31 -03:00
gffranco 7e757c6a7f release: fix restore-keys pipe syntax for Gitea 2026-06-03 22:17:31 -03:00
gffranco a5a07768d6 ci: fix restore-keys to use pipe syntax (Gitea doesn't support YAML list) 2026-06-03 22:17:31 -03:00
gffranco 08ebdc6f9b ci: remove concurrency key (not supported by Gitea Actions) 2026-06-03 22:17:31 -03:00
gffranco b77f4b1ced ci: improve workflows with stricter checks, caching, and idempotency
CI improvements:
- Add concurrency group to cancel redundant runs on same branch
- Add cargo cache to fmt job (was missing entirely)
- Add timeout-minutes: 5 to fast jobs (fmt, clippy, test) for fail-fast
- Change RUST_BACKTRACE from 'short' to '1' for better CI debuggability
- Pin push trigger to branches: [main] to avoid double-firing on PR merges
- Test minimum supported Neovim (0.11.0) instead of latest patch (0.11.3)

Release improvements:
- Enforce RUSTFLAGS: -D warnings at env level and in matrix rustflags
- Remove redundant checkout in release job (only needs env vars)
- Add release notes generation from git log since previous tag
- Make release creation idempotent (check if release exists before creating)
- Fix release_id not being set when release already exists
2026-06-03 22:17:31 -03:00
gffranco c9d75aeb1f Ci improvements (#5)
CI / cargo fmt --check (push) Successful in 42s
CI / cargo clippy (push) Successful in 34s
CI / cargo test (push) Successful in 38s
CI / editor keymaps (push) Successful in 1m32s
Reviewed-on: #5
Co-authored-by: Gabriel Fróes Franco <gffranco@gmail.com>
Co-committed-by: Gabriel Fróes Franco <gffranco@gmail.com>
2026-06-04 01:06:53 +00:00
50 changed files with 2181 additions and 287 deletions
+6
View File
@@ -120,3 +120,9 @@ jobs:
run: ./development/tests/test-calendar-vim.sh
- name: run Vim vars-shim harness
run: ./development/tests/test-vars-vim.sh
- name: run Vim vimwiki-config compat harness
run: ./development/tests/test-vimwiki-compat-vim.sh
- name: run Neovim global-shorthand harness
run: ./development/tests/test-global-shorthand.sh
- name: run Vim coc-registration harness
run: ./development/tests/test-coc-register-vim.sh
+113 -22
View File
@@ -1,9 +1,17 @@
name: Release
# Releases are cut from the Actions UI: "Run workflow" → enter the version
# (e.g. 0.4.0). The `prepare` job stamps that version into every hardcoded
# spot (scripts/set-version.sh), commits "chore(release): X.Y.Z", and pushes
# the vX.Y.Z tag. The build matrix + release job then run off that tag. No
# more hand-editing versions across Cargo.toml/plugin/nuwiki.vim.
on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
version:
description: 'Release version, no leading v (e.g. 0.4.0)'
required: true
type: string
permissions:
contents: write
@@ -14,8 +22,77 @@ env:
RUSTFLAGS: -D warnings
jobs:
prepare:
name: bump + tag
runs-on: ubuntu-latest
outputs:
version: ${{ steps.stamp.outputs.version }}
tag: ${{ steps.stamp.outputs.tag }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
# Push the release commit + tag back with a write-capable token.
token: ${{ secrets.RELEASE_TOKEN }}
- uses: dtolnay/rust-toolchain@1.83
- name: Cache cargo state
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-prepare-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-prepare-
- name: Validate version + stamp
id: stamp
env:
VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
ver="${VERSION#v}" # tolerate a leading v
if ! printf '%s' "$ver" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.-]+)?$'; then
echo "::error::'$ver' is not a semver version (expected e.g. 0.4.0)"
exit 1
fi
tag="v$ver"
if git rev-parse -q --verify "refs/tags/$tag" >/dev/null \
|| git ls-remote --exit-code --tags origin "$tag" >/dev/null 2>&1; then
echo "::error::tag $tag already exists"
exit 1
fi
bash scripts/set-version.sh "$ver"
echo "version=$ver" >> "$GITHUB_OUTPUT"
echo "tag=$tag" >> "$GITHUB_OUTPUT"
# Gate: never tag code that doesn't build/test. If this fails nothing
# is committed or pushed, so the release simply doesn't happen.
- name: Test
run: cargo test --workspace
- name: Commit, tag, push
env:
VER: ${{ steps.stamp.outputs.version }}
TAG: ${{ steps.stamp.outputs.tag }}
run: |
set -euo pipefail
git config user.name "gitea-actions"
git config user.email "gitea-actions@users.noreply.code.gfran.co"
git add Cargo.toml Cargo.lock \
crates/nuwiki-lsp/Cargo.toml crates/nuwiki-ls/Cargo.toml \
plugin/nuwiki.vim
git commit -m "chore(release): $VER"
git tag -a "$TAG" -m "nuwiki $VER"
git push origin "HEAD:${GITHUB_REF_NAME}"
git push origin "$TAG"
build:
name: build ${{ matrix.target }}
needs: prepare
runs-on: ubuntu-latest
strategy:
fail-fast: false
@@ -38,6 +115,9 @@ jobs:
rustflags: "-D warnings -C linker=rust-lld -C link-self-contained=yes"
steps:
- uses: actions/checkout@v4
with:
# Build the just-tagged commit, not the branch tip.
ref: ${{ needs.prepare.outputs.tag }}
- uses: dtolnay/rust-toolchain@1.83
with:
@@ -72,15 +152,19 @@ jobs:
- name: Package archive
id: package
env:
VERSION: ${{ needs.prepare.outputs.version }}
run: |
set -euo pipefail
version="${GITHUB_REF_NAME#v}"
archive="nuwiki-ls-${version}-${{ matrix.target }}.tar.gz"
archive="nuwiki-ls-${VERSION}-${{ matrix.target }}.tar.gz"
tar -czf "$archive" -C "target/${{ matrix.target }}/release" nuwiki-ls
echo "archive=$archive" >> "$GITHUB_OUTPUT"
# Use a stable name without version so /releases/latest/download/nuwiki-ls-{target}.tar.gz always resolves.
stable="nuwiki-ls-${{ matrix.target }}.tar.gz"
mv "$archive" "$stable"
echo "archive=$stable" >> "$GITHUB_OUTPUT"
- name: Upload build artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v3
with:
name: nuwiki-ls-${{ matrix.target }}
path: ${{ steps.package.outputs.archive }}
@@ -88,16 +172,20 @@ jobs:
release:
name: gitea release
needs: build
needs: [prepare, build]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.prepare.outputs.tag }}
fetch-depth: 0
- name: Download all build artifacts
# upload-artifact@v4 + download-artifact@v4 with merge-multiple
# flattens all artifacts into the download path directly.
uses: actions/download-artifact@v4
# download-artifact@v3 nests each artifact under its own dir
# (artifacts/<artifact-name>/<file>), so recurse.
uses: actions/download-artifact@v3
with:
path: ./artifacts
merge-multiple: true
- name: Ensure jq + curl
run: |
@@ -110,18 +198,20 @@ jobs:
- name: Generate release notes
id: notes
env:
TAG: ${{ needs.prepare.outputs.tag }}
run: |
set -euo pipefail
# Get commits since the previous tag (or the first commit if this is the first release).
prev_tag=$(git tag --sort=-version:refname | awk "NR==2" || echo "")
git fetch --tags --force origin >/dev/null 2>&1 || true
# Previous tag = newest tag that isn't the one we just cut.
prev_tag=$(git tag --sort=-version:refname | grep -vxF "$TAG" | head -n1 || echo "")
if [ -n "$prev_tag" ]; then
log=$(git log --oneline "$prev_tag"..HEAD)
log=$(git log --oneline "$prev_tag..$TAG")
else
log=$(git log --oneline --max-parents=0..HEAD)
log=$(git log --oneline "$TAG")
fi
# Write notes to a file for later use.
cat > release-notes.txt <<EOF
**Full Changelog**: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/compare/${prev_tag:-$(git rev-list --max-parents=0 HEAD)}...${GITHUB_REF_NAME}
**Full Changelog**: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/compare/${prev_tag:-$(git rev-list --max-parents=0 HEAD)}...${TAG}
**Changes**:
$log
@@ -133,7 +223,7 @@ jobs:
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
GITEA_SERVER: ${{ github.server_url }}
REPO: ${{ github.repository }}
TAG: ${{ github.ref_name }}
TAG: ${{ needs.prepare.outputs.tag }}
run: |
set -euo pipefail
if [ -z "${RELEASE_TOKEN:-}" ]; then
@@ -147,7 +237,7 @@ jobs:
existing=$(curl --fail --silent --show-error -o /dev/null -w "%{http_code}" \
-H "Authorization: token $RELEASE_TOKEN" \
"$GITEA_SERVER/api/v1/repos/$REPO/releases/tags/$TAG" \
|| true)
2>/dev/null || true)
if [ "$existing" = "200" ]; then
echo "Release for $TAG already exists — fetching existing id"
release_id=$(curl --fail --silent --show-error \
@@ -155,8 +245,9 @@ jobs:
"$GITEA_SERVER/api/v1/repos/$REPO/releases/tags/$TAG" \
| jq -r '.id')
else
payload=$(jq -n --arg tag "$TAG" --arg name "$TAG" \
'{tag_name: $tag, name: $name, draft: false, prerelease: false}')
notes=$(cat release-notes.txt 2>/dev/null || echo "")
payload=$(jq -n --arg tag "$TAG" --arg name "$TAG" --arg body "$notes" \
'{tag_name: $tag, name: $name, body: $body, draft: false, prerelease: false}')
release_id=$(curl --fail --silent --show-error \
-H "Authorization: token $RELEASE_TOKEN" \
-H "Content-Type: application/json" \
Generated
+3 -3
View File
@@ -376,11 +376,11 @@ checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "nuwiki-core"
version = "0.1.0"
version = "0.4.2"
[[package]]
name = "nuwiki-ls"
version = "0.1.0"
version = "0.4.2"
dependencies = [
"nuwiki-lsp",
"tokio",
@@ -388,7 +388,7 @@ dependencies = [
[[package]]
name = "nuwiki-lsp"
version = "0.1.0"
version = "0.4.2"
dependencies = [
"async-trait",
"dashmap 6.1.0",
+1 -1
View File
@@ -7,7 +7,7 @@ members = [
]
[workspace.package]
version = "0.1.0"
version = "0.4.2"
edition = "2021"
rust-version = "1.83"
authors = ["Gabriel Fróes Franco <gffranco@gmail.com>"]
+37 -9
View File
@@ -82,9 +82,9 @@ Neovim) instead of relying on a plugin-manager build hook.
Older releases fall back to a `vim.lsp.start` autocmd but aren't
officially supported.
- **Vim 9+** — needs a third-party LSP client:
[`vim-lsp`](https://github.com/prabirshrestha/vim-lsp) (recommended,
auto-registered) or [`coc.nvim`](https://github.com/neoclide/coc.nvim)
(one-time `coc-settings.json` entry — see [Vim LSP client
[`vim-lsp`](https://github.com/prabirshrestha/vim-lsp) or
[`coc.nvim`](https://github.com/neoclide/coc.nvim) — both
auto-registered, no manual config (see [Vim LSP client
setup](#vim-lsp-client-setup)).
- **Rust toolchain (1.83+ stable)** — only needed when building the
binary from source; pre-built release downloads skip this.
@@ -145,10 +145,18 @@ auto-enables it. Your wiki settings (`g:nuwiki_wiki_root`,
`g:nuwiki_wikis`, …) are forwarded as the server's initialization
options.
**coc.nvim**[`coc.nvim`](https://github.com/neoclide/coc.nvim) reads
its server list from `coc-settings.json`, which the plugin can't edit
on your behalf, so add the entry once yourself (open it with
`:CocConfig`):
**coc.nvim**also zero configuration. nuwiki registers itself with
[`coc.nvim`](https://github.com/neoclide/coc.nvim) programmatically
(`coc#config('languageserver.nuwiki', …)`) when the first `.wiki` buffer
loads, forwarding the same wiki settings (`g:nuwiki_wiki_root`,
`g:nuwiki_wikis`, your `g:vimwiki_*` config, the global shorthand, …) as
the server's initialization options. **No `coc-settings.json`
`languageserver` entry needed** — change your wiki config and reload; the
new settings are picked up.
To manage the entry yourself instead, set `let g:nuwiki_no_coc_register = 1`
and add it to `coc-settings.json` manually (nuwiki then just prints the
snippet on first load):
```json
{
@@ -337,6 +345,14 @@ The `<Leader>w` prefix in the `wiki_prefix`, `links`, and `html_export` rows is
#### Per-wiki keys (`wikis[]`)
Most display/generation keys below can also be set **once at the top level**
as a default for every wiki (vimwiki-style global shorthand) — via `setup({ … })`
or `g:nuwiki_<key>` — and a per-wiki value overrides it. The keys that honour
this are `toc_header`, `toc_header_level`, `toc_link_format`, `links_header`,
`links_header_level`, `tags_header`, `tags_header_level`,
`html_header_numbering`, `html_header_numbering_sym`, `links_space_char`,
`list_margin`, `listsyms`, `listsym_rejected`, and the `auto_*` toggles.
| Key | Type | Default | Accepted values |
|-----|------|---------|-----------------|
| `name` | string | — | any label |
@@ -660,8 +676,20 @@ match vimwiki. To migrate:
1. Drop the original `vimwiki` plugin from your config.
2. Install nuwiki.
3. Point `wiki_root` (or each `wikis[i].root`) at your existing
directory.
3. Keep your existing config, or point `wiki_root` / `wikis[i].root` at
your directory.
**Existing `g:vimwiki_list` config works as-is (Vim & Neovim).** When you
haven't configured nuwiki natively (`g:nuwiki_wikis` / `setup()` / `g:nuwiki_*`),
both clients read your upstream `g:vimwiki_list` and `g:vimwiki_*` globals and
translate them: each wiki's `path`/`path_html`/`template_*`/`auto_export`
plus globals like `toc_header_level`, `html_header_numbering`,
`html_header_numbering_sym`, `links_space_char`, and `list_margin`.
Native config always takes precedence. (Settings nuwiki implements
differently — see [`known-issues.md`](./known-issues.md) — are ignored.)
On Neovim with lazy.nvim, swap the `vimwiki` plugin spec for nuwiki and
call `require('nuwiki').setup()` — your `vim.g.vimwiki_list` is picked up
unchanged.
Existing pages, diary entries, tags, and templates work without
modification. The first workspace scan after launch may show an empty
+22 -16
View File
@@ -588,14 +588,13 @@ endfunction
" open their wiki from any buffer. Files are opened directly from config;
" the LSP auto-starts via the FileType autocmd once the buffer loads.
" Return a dict with {root, ext, idx, diary_rel, diary_idx} for wiki #n
" (0-based). Prefers g:nuwiki_wikis[n]; falls back to scalar g:nuwiki_*
" vars and finally to built-in defaults.
" Return a dict with {root, ext, idx, diary_rel, diary_idx, space} for wiki #n
" (0-based). Resolves through nuwiki#config#wikis() — so g:nuwiki_wikis AND an
" upstream g:vimwiki_list both work — and falls back to the scalar g:nuwiki_*
" vars (single-wiki shorthand) and finally built-in defaults.
function! s:wiki_cfg(n) abort
let l:w = {}
if exists('g:nuwiki_wikis') && len(g:nuwiki_wikis) > a:n
let l:w = g:nuwiki_wikis[a:n]
endif
let l:wikis = nuwiki#config#wikis()
let l:w = (a:n >= 0 && a:n < len(l:wikis)) ? l:wikis[a:n] : {}
let l:root = expand(get(l:w, 'root',
\ get(g:, 'nuwiki_wiki_root', '~/vimwiki')))
let l:ext = get(l:w, 'file_extension',
@@ -612,14 +611,15 @@ function! s:wiki_cfg(n) abort
endfunction
" Build the list of configured wikis as {name, root, ext, idx, diary_rel,
" diary_idx} dicts, straight from config — no LSP round-trip. Drives the wiki
" picker so it works before any wiki buffer (and therefore the server) exists.
" Falls back to a single entry from the scalar `g:nuwiki_wiki_root` config.
" diary_idx, space} dicts, straight from config — no LSP round-trip. Drives the
" wiki picker so it works before any wiki buffer (and therefore the server)
" exists. Falls back to a single entry from the scalar `g:nuwiki_wiki_root`.
function! nuwiki#commands#wiki_list() abort
let l:wikis = nuwiki#config#wikis()
let l:list = []
if exists('g:nuwiki_wikis') && !empty(g:nuwiki_wikis)
if !empty(l:wikis)
let l:n = 0
for l:w in g:nuwiki_wikis
for l:w in l:wikis
let l:c = s:wiki_cfg(l:n)
let l:c['name'] = get(l:w, 'name', fnamemodify(l:c.root, ':t'))
call add(l:list, l:c)
@@ -725,18 +725,23 @@ endfunction
" ===== Generation + workspace =====
function! nuwiki#commands#toc_generate() abort
call s:exec('nuwiki.toc.generate', [{'uri': s:buf_uri()}])
" Send the cursor line (0-based) so a fresh TOC is inserted there.
call s:exec('nuwiki.toc.generate',
\ [{'uri': s:buf_uri(), 'line': line('.') - 1}])
endfunction
" `:VimwikiGenerateLinks [rel_path]` — with no arg, list every page; with a
" rel-path arg, scope the generated links to that subtree (server-side
" `generateForPath`). Matches upstream's optional path argument.
function! nuwiki#commands#links_generate(...) abort
let l:path = a:0 >= 1 ? a:1 : ''
" Cursor line (0-based) so a fresh section is inserted there.
let l:line = line('.') - 1
if l:path !=# ''
call s:exec('nuwiki.links.generateForPath',
\ [{'uri': s:buf_uri(), 'path': l:path}])
\ [{'uri': s:buf_uri(), 'path': l:path, 'line': l:line}])
else
call s:exec('nuwiki.links.generate', [{'uri': s:buf_uri()}])
call s:exec('nuwiki.links.generate',
\ [{'uri': s:buf_uri(), 'line': l:line}])
endif
endfunction
function! nuwiki#commands#check_links(...) abort
@@ -798,7 +803,8 @@ function! nuwiki#commands#tags_search(query) abort
endfunction
function! nuwiki#commands#tags_generate_links(tag) abort
let l:args = { 'uri': s:buf_uri() }
" Cursor line (0-based) so a fresh section is inserted there.
let l:args = { 'uri': s:buf_uri(), 'line': line('.') - 1 }
if a:tag !=# ''
let l:args['tag'] = a:tag
endif
+103
View File
@@ -0,0 +1,103 @@
" autoload/nuwiki/config.vim — single source of truth for the configured wiki
" list, shared by the LSP payload (lsp.vim) and the buffer-side commands
" (commands.vim, diary.vim, complete.vim).
"
" Resolves wikis from g:nuwiki_wikis (native) or, when that's unset, an upstream
" g:vimwiki_list (drop-in compatibility), translating upstream key names and
" folding the global scalar defaults (g:vimwiki_* / g:nuwiki_*) into each entry.
" Everything downstream — `<Leader>ww`, link resolution, the server payload —
" then sees the same wikis.
" Per-wiki dict keys: upstream `g:vimwiki_list[i]` key -> nuwiki/server key.
let s:vimwiki_wiki_map = {
\ 'path': 'root',
\ 'path_html': 'html_path',
\ 'template_path': 'template_path',
\ 'template_default': 'template_default',
\ 'template_ext': 'template_ext',
\ 'css_name': 'css_name',
\ 'auto_export': 'auto_export',
\ 'auto_toc': 'auto_toc',
\ 'syntax': 'syntax',
\ 'ext': 'file_extension',
\ 'index': 'index',
\ 'diary_rel_path': 'diary_rel_path',
\ 'diary_index': 'diary_index',
\ 'name': 'name',
\ }
" Global scalar settings vimwiki applies to every wiki (via wikilocal
" defaults): upstream suffix (`g:vimwiki_<suffix>`) -> nuwiki/server key.
let s:scalar_global_map = {
\ 'toc_header': 'toc_header',
\ 'toc_header_level': 'toc_header_level',
\ 'toc_link_format': 'toc_link_format',
\ 'links_header': 'links_header',
\ 'links_header_level': 'links_header_level',
\ 'tags_header': 'tags_header',
\ 'tags_header_level': 'tags_header_level',
\ 'html_header_numbering': 'html_header_numbering',
\ 'html_header_numbering_sym': 'html_header_numbering_sym',
\ 'links_space_char': 'links_space_char',
\ 'list_margin': 'list_margin',
\ 'listsyms': 'listsyms',
\ 'listsym_rejected': 'listsym_rejected',
\ 'auto_export': 'auto_export',
\ 'auto_toc': 'auto_toc',
\ 'auto_generate_links': 'auto_generate_links',
\ 'auto_generate_tags': 'auto_generate_tags',
\ 'auto_diary_index': 'auto_diary_index',
\ }
" Resolve the per-wiki scalar globals: vimwiki values first (compat), then
" nuwiki-native `g:nuwiki_<key>` overrides on top.
function! nuwiki#config#scalar_globals() abort
let l:g = {}
for [l:src, l:dst] in items(s:scalar_global_map)
if exists('g:vimwiki_' . l:src)
let l:g[l:dst] = get(g:, 'vimwiki_' . l:src)
endif
endfor
for l:dst in values(s:scalar_global_map)
if exists('g:nuwiki_' . l:dst)
let l:g[l:dst] = get(g:, 'nuwiki_' . l:dst)
endif
endfor
return l:g
endfunction
" Translate an upstream g:vimwiki_list into nuwiki's wiki list, folding the
" given global scalars into each entry. Returns [] when unset/malformed.
function! s:wikis_from_vimwiki(globals) abort
if !exists('g:vimwiki_list') || type(g:vimwiki_list) != type([]) || empty(g:vimwiki_list)
return []
endif
let l:wikis = []
for l:vw in g:vimwiki_list
if type(l:vw) != type({}) | continue | endif
let l:w = copy(a:globals)
for [l:src, l:dst] in items(s:vimwiki_wiki_map)
if has_key(l:vw, l:src)
let l:w[l:dst] = l:vw[l:src]
endif
endfor
if has_key(l:w, 'root')
call add(l:wikis, l:w)
endif
endfor
return l:wikis
endfunction
" The configured wikis as normalized dicts (nuwiki/server key names), with the
" global scalar defaults folded in (per-wiki value wins). Priority:
" 1. g:nuwiki_wikis (native)
" 2. g:vimwiki_list (upstream drop-in)
" Returns [] when neither is set — callers fall back to the single-wiki
" shorthand (g:nuwiki_wiki_root).
function! nuwiki#config#wikis() abort
let l:globals = nuwiki#config#scalar_globals()
if exists('g:nuwiki_wikis') && !empty(g:nuwiki_wikis)
return map(copy(g:nuwiki_wikis), {_, w -> extend(copy(l:globals), w, 'force')})
endif
return s:wikis_from_vimwiki(l:globals)
endfunction
+36 -10
View File
@@ -3,6 +3,33 @@
" ===== Calendar-Vim Integration =====
" The wiki index (0-based) of the most recently entered wiki buffer. The
" calendar shows / creates diary entries for this wiki, so opening the calendar
" while editing `ifood_wiki` diaries into ifood_wiki — not always the first
" wiki. Updated by the BufEnter autocmd in plugin/nuwiki.vim; defaults to 0.
" (The LSP-driven diary commands already follow the current buffer via its URI;
" this mirrors that for the VimL calendar-vim callbacks.)
let s:current_wiki_idx = 0
" Record the wiki that owns `file` as the current one. Called on entering a
" wiki buffer. No-op when the file is under no configured wiki.
function! nuwiki#diary#track_wiki(file) abort
let l:file = fnamemodify(a:file, ':p')
if empty(l:file)
return
endif
let l:n = 0
for l:w in nuwiki#commands#wiki_list()
let l:root = fnamemodify(expand(l:w.root), ':p')
if l:root[len(l:root) - 1:] !=# '/' | let l:root .= '/' | endif
if l:file[: len(l:root) - 1] ==# l:root
let s:current_wiki_idx = l:n
return
endif
let l:n += 1
endfor
endfunction
" Function: nuwiki#diary#calendar_action(day, month, year, week, dir)
" Called by calendar-vim when user presses Enter on a date.
" day/month/year are numeric integers; dir is 'V' for a vertical split (any
@@ -12,8 +39,8 @@ function! nuwiki#diary#calendar_action(day, month, year, week, dir) abort
" Build YYYY-MM-DD from the parts passed by calendar-vim
let l:date_str = printf('%04d-%02d-%02d', a:year, a:month, a:day)
" Get wiki configuration for the first wiki (index 0)
let l:c = s:wiki_cfg(0)
" Wiki of the buffer the calendar was opened from (falls back to the first).
let l:c = s:wiki_cfg(s:current_wiki_idx)
" Construct file path: {root}/{diary_rel}/{YYYY-MM-DD}.{ext}
let l:file_path = l:c.root . '/' . l:c.diary_rel . '/' . l:date_str . l:c.ext
@@ -62,8 +89,8 @@ function! nuwiki#diary#calendar_sign(day, month, year) abort
" Build YYYY-MM-DD from the parts passed by calendar-vim
let l:date_str = printf('%04d-%02d-%02d', a:year, a:month, a:day)
" Get wiki configuration for the first wiki (index 0)
let l:c = s:wiki_cfg(0)
" Wiki of the buffer the calendar was opened from (falls back to the first).
let l:c = s:wiki_cfg(s:current_wiki_idx)
" Construct expected file path
let l:file_path = l:c.root . '/' . l:c.diary_rel . '/' . l:date_str . l:c.ext
@@ -75,13 +102,12 @@ function! nuwiki#diary#calendar_sign(day, month, year) abort
return ''
endfunction
" Helper: get wiki configuration for wiki n (0-based).
" Mirrors the same logic used by nuwiki#commands#open_diary_path etc.
" Helper: get wiki configuration for wiki n (0-based). Resolves through
" nuwiki#config#wikis() so g:nuwiki_wikis AND an upstream g:vimwiki_list both
" work, falling back to the scalar g:nuwiki_* single-wiki shorthand.
function! s:wiki_cfg(n) abort
let l:w = {}
if exists('g:nuwiki_wikis') && len(g:nuwiki_wikis) > a:n
let l:w = g:nuwiki_wikis[a:n]
endif
let l:wikis = nuwiki#config#wikis()
let l:w = (a:n >= 0 && a:n < len(l:wikis)) ? l:wikis[a:n] : {}
let l:root = expand(get(l:w, 'root',
\ get(g:, 'nuwiki_wiki_root', '~/vimwiki')))
let l:ext = get(l:w, 'file_extension',
+71 -22
View File
@@ -45,22 +45,7 @@ function! nuwiki#lsp#start() abort
endif
if exists(':CocConfig') == 2 || exists('*coc#config')
" coc.nvim — coc reads its server list from coc-settings.json.
" We can't inject it dynamically without owning the file, so just point
" the user at the snippet. `initializationOptions` carries wiki_root etc.
" to the server exactly like the vim-lsp registration above — without it
" the server resolves links against its default root, so existing pages
" look broken and follow-to-create lands in the wrong directory.
echohl WarningMsg
echom 'nuwiki: coc.nvim detected. Add this to your coc-settings.json:'
echom ' "languageserver": {'
echom ' "nuwiki": {'
echom ' "command": "' . l:bin . '",'
echom ' "filetypes": ["vimwiki"],'
echom ' "initializationOptions": ' . json_encode(s:settings())
echom ' }'
echom ' }'
echohl None
call s:register_with_coc(l:bin)
return
endif
@@ -71,6 +56,66 @@ function! nuwiki#lsp#start() abort
echohl None
endfunction
" ===== coc.nvim integration =====
"
" Register the nuwiki language server with coc *programmatically* via
" coc#config, so users don't have to hand-maintain a coc-settings.json entry
" per wiki. The config (wiki roots, per-wiki keys, the vimwiki/nuwiki global
" shorthand) comes from s:settings() — the same payload the vim-lsp path sends.
" coc reacts to the `languageserver` config change, registers the server, and
" starts it for the open vimwiki buffer.
"
" Opt out with `let g:nuwiki_no_coc_register = 1` to manage the entry in
" coc-settings.json yourself (we then just print the snippet).
function! s:register_with_coc(bin) abort
if get(g:, 'nuwiki_no_coc_register', 0)
call s:print_coc_snippet(a:bin)
return
endif
let s:coc_bin = a:bin
if get(g:, 'coc_service_initialized', 0)
call s:coc_register()
else
" coc hasn't finished starting its node service yet — defer until it has,
" otherwise the updateConfig notification is sent to a service that isn't
" listening.
augroup nuwiki_coc_register
autocmd!
autocmd User CocNvimInit ++once call s:coc_register()
augroup END
endif
endfunction
function! s:coc_register() abort
let l:cfg = s:settings()
" Call coc#config directly — `exists('*coc#config')` can't be trusted because
" it doesn't trigger autoloading, so we let the call autoload coc.vim and
" fall back to the manual snippet only if coc genuinely isn't there.
try
call coc#config('languageserver.nuwiki', {
\ 'command': s:coc_bin,
\ 'filetypes': ['vimwiki'],
\ 'initializationOptions': l:cfg,
\ 'settings': { 'nuwiki': l:cfg },
\ })
catch /^Vim\%((\a\+)\)\=:E11[78]/
call s:print_coc_snippet(s:coc_bin)
endtry
endfunction
function! s:print_coc_snippet(bin) abort
echohl WarningMsg
echom 'nuwiki: coc.nvim detected. Add this to your coc-settings.json:'
echom ' "languageserver": {'
echom ' "nuwiki": {'
echom ' "command": "' . a:bin . '",'
echom ' "filetypes": ["vimwiki"],'
echom ' "initializationOptions": ' . json_encode(s:settings())
echom ' }'
echom ' }'
echohl None
endfunction
function! s:bin_path() abort
if exists('g:nuwiki_binary_path') && filereadable(g:nuwiki_binary_path)
return g:nuwiki_binary_path
@@ -89,12 +134,16 @@ function! s:settings() abort
\ 'link_severity': get(g:, 'nuwiki_link_severity', 'warn'),
\ },
\ }
" Include the per-wiki list when configured via g:nuwiki_wikis so the
" server knows each wiki's root, diary path, etc. Without this it only
" sees wiki_root and resolves links relative to that directory instead
" of the individual wiki's root.
if exists('g:nuwiki_wikis') && !empty(g:nuwiki_wikis)
let l:cfg['wikis'] = g:nuwiki_wikis
" Wiki list comes from the shared resolver (nuwiki#config#wikis), so the
" server payload and the buffer-side commands agree on which wikis exist —
" native g:nuwiki_wikis or an upstream g:vimwiki_list, globals already folded.
let l:wikis = nuwiki#config#wikis()
if !empty(l:wikis)
let l:cfg['wikis'] = l:wikis
else
" Single-wiki shorthand: fold the global scalars into the top-level payload
" so the server's single-wiki desugaring honours them.
call extend(l:cfg, nuwiki#config#scalar_globals(), 'force')
endif
return l:cfg
endfunction
+4
View File
@@ -53,6 +53,10 @@ pub struct PreformattedNode {
pub span: Span,
pub content: String,
pub language: Option<String>,
/// Verbatim text after the opening `{{{`, trailing whitespace trimmed
/// (e.g. `python` or `class="brush: python"`). vimwiki inserts this as
/// raw attributes on the `<pre>` tag, so we preserve it for byte-parity.
pub attrs: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
+17
View File
@@ -17,6 +17,22 @@ pub enum Keyword {
Stopped,
}
impl Keyword {
/// The literal source text of the keyword (e.g. `"TODO"`). Used both for
/// the rendered span and for anchor/id derivation via [`inline_text`].
pub fn label(self) -> &'static str {
match self {
Keyword::Todo => "TODO",
Keyword::Done => "DONE",
Keyword::Started => "STARTED",
Keyword::Fixme => "FIXME",
Keyword::Fixed => "FIXED",
Keyword::Xxx => "XXX",
Keyword::Stopped => "STOPPED",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum InlineNode {
Text(TextNode),
@@ -89,6 +105,7 @@ fn push_inline_text(inlines: &[InlineNode], out: &mut String) {
InlineNode::Superscript(s) => push_inline_text(&s.children, out),
InlineNode::Subscript(s) => push_inline_text(&s.children, out),
InlineNode::Color(c) => push_inline_text(&c.children, out),
InlineNode::Keyword(k) => out.push_str(k.keyword.label()),
InlineNode::WikiLink(w) => match &w.description {
Some(d) => push_inline_text(d, out),
None => {
+155 -35
View File
@@ -59,6 +59,11 @@ pub struct HtmlRenderer {
/// vimwiki `html_header_numbering_sym`: a symbol appended after the
/// section number (e.g. `.` → `1.2. Heading`). Empty by default.
header_numbering_sym: String,
/// vimwiki `toc_header`: the heading text that identifies the TOC
/// section. A heading matching this value (case-insensitive) is wrapped
/// in `<div class="toc">…</div>` on export, matching upstream so vimwiki's
/// stylesheet applies.
toc_header: String,
/// vimwiki `valid_html_tags`: inline HTML tag names passed through to
/// export verbatim (instead of escaping `<`/`>`). Empty = escape all.
/// `span` is always allowed so colour spans render.
@@ -90,6 +95,7 @@ impl HtmlRenderer {
color_template: "<span style=\"__STYLE__\">__CONTENT__</span>".to_string(),
header_numbering: 0,
header_numbering_sym: String::new(),
toc_header: String::new(),
valid_html_tags: Vec::new(),
emoji_enable: false,
text_ignore_newline: true,
@@ -183,6 +189,13 @@ impl HtmlRenderer {
self.header_numbering_sym = sym.into();
self
}
/// Set the vimwiki TOC heading text. When a heading matches this value
/// (case-insensitive) it gets `class="toc"` in HTML export.
pub fn with_toc_header(mut self, header: impl Into<String>) -> Self {
self.toc_header = header.into();
self
}
}
/// Running state for vimwiki-style HTML section numbering
@@ -265,10 +278,26 @@ impl HtmlRenderer {
// quotes) go through `render_block` and are left unnumbered, matching
// upstream which only numbers document-level headers.
let mut numberer = HeadingNumberer::new(self.header_numbering, &self.header_numbering_sym);
// Stack of (level, anchor) for the headings enclosing the current one,
// so each heading can carry vimwiki's hierarchical id (the parent
// anchors joined by `-`, e.g. `Title-Section`).
let mut ancestors: Vec<(u8, String)> = Vec::new();
for block in &doc.children {
if let BlockNode::Heading(n) = block {
let number = numberer.prefix(n.level.clamp(1, 6));
self.render_heading(n, &number, w)?;
let level = n.level.clamp(1, 6);
let number = numberer.prefix(level);
let anchor = crate::ast::inline_text(&n.children);
while ancestors.last().is_some_and(|(l, _)| *l >= level) {
ancestors.pop();
}
let mut hier = String::new();
for (_, a) in &ancestors {
hier.push_str(a);
hier.push('-');
}
hier.push_str(&anchor);
ancestors.push((level, anchor));
self.render_heading(n, &number, &hier, w)?;
} else {
self.render_block(block, w)?;
}
@@ -278,7 +307,7 @@ impl HtmlRenderer {
fn render_block(&self, block: &BlockNode, w: &mut dyn Write) -> io::Result<()> {
match block {
BlockNode::Heading(n) => self.render_heading(n, "", w),
BlockNode::Heading(n) => self.render_heading(n, "", "", w),
BlockNode::Paragraph(n) => self.render_paragraph(n, w),
BlockNode::HorizontalRule(n) => self.render_hr(n, w),
BlockNode::Blockquote(n) => self.render_blockquote(n, w),
@@ -294,13 +323,14 @@ impl HtmlRenderer {
}
fn render_tag(&self, n: &TagNode, w: &mut dyn Write) -> io::Result<()> {
// `id="tag-…"` lets `[[Page#tag-name]]` jump to the rendered tag.
// `id="…"` (bare tag name, matching vimwiki) lets `[[Page#name]]` jump
// to the rendered tag.
w.write_all(b"<div class=\"tags\">")?;
for (i, name) in n.tags.iter().enumerate() {
if i > 0 {
w.write_all(b" ")?;
}
w.write_all(b"<span class=\"tag\" id=\"tag-")?;
w.write_all(b"<span class=\"tag\" id=\"")?;
write_escaped(name, w)?;
w.write_all(b"\">")?;
write_escaped(name, w)?;
@@ -311,26 +341,71 @@ impl HtmlRenderer {
/// Render a heading. `number` is the optional section-number prefix
/// (already including its trailing symbol and space, e.g. `"1.2. "`);
/// pass `""` for no numbering.
fn render_heading(&self, n: &HeadingNode, number: &str, w: &mut dyn Write) -> io::Result<()> {
/// pass `""` for no numbering. `hier_id` is the hierarchical anchor id
/// (parent anchors joined by `-`); pass `""` to default it to the flat id.
fn render_heading(
&self,
n: &HeadingNode,
number: &str,
hier_id: &str,
w: &mut dyn Write,
) -> io::Result<()> {
let level = n.level.clamp(1, 6);
let class = if n.centered {
" class=\"centered\""
} else {
""
};
// `id` is the heading's plain text (vimwiki's anchor scheme), so
// `[[Page#Heading]]` and TOC `#Heading` links land here. Matches the
// anchor text the LSP validates and the resolver emits.
let anchor = crate::ast::inline_text(&n.children);
write!(w, "<h{level}{class} id=\"")?;
// Inside a heading, render TODO/DONE/… as plain text rather than a
// keyword badge — a section title that *is* a keyword (`== TODO ==`)
// should look like a header, not an inline tag. (The keyword text is
// still part of `anchor` above, so the id stays correct.)
let body = flatten_keywords(&n.children);
// The TOC section heading (vimwiki `toc_header`) is wrapped in a
// `<div class="toc">`, matching upstream so vimwiki's `.toc { … }`
// stylesheet rules apply. Kept in its simple form.
let is_toc = !self.toc_header.is_empty() && anchor.eq_ignore_ascii_case(&self.toc_header);
if is_toc {
w.write_all(b"<div class=\"toc\">")?;
write!(w, "<h{level} id=\"")?;
write_escaped(&anchor, w)?;
write!(w, "\">")?;
if !number.is_empty() {
write_escaped(number, w)?;
}
self.render_inlines(&n.children, w)?;
writeln!(w, "</h{level}>")
self.render_inlines(&body, w)?;
write!(w, "</h{level}></div>")?;
return writeln!(w);
}
// Centered headings keep their `class="centered"` form.
if n.centered {
write!(w, "<h{level} class=\"centered\" id=\"")?;
write_escaped(&anchor, w)?;
write!(w, "\">")?;
if !number.is_empty() {
write_escaped(number, w)?;
}
self.render_inlines(&body, w)?;
write!(w, "</h{level}>")?;
return writeln!(w);
}
// Regular heading: vimwiki's structure — a wrapping `<div>` carrying the
// hierarchical id, `class="header"` on the heading, and an in-heading
// self-anchor linking to that id. The heading keeps the flat id so
// existing intra-page TOC / `#anchor` links still resolve.
let hier = if hier_id.is_empty() { &anchor } else { hier_id };
w.write_all(b"<div id=\"")?;
write_escaped(hier, w)?;
write!(w, "\"><h{level} id=\"")?;
write_escaped(&anchor, w)?;
w.write_all(b"\" class=\"header\"><a href=\"#")?;
write_escaped(hier, w)?;
write!(w, "\">")?;
if !number.is_empty() {
write_escaped(number, w)?;
}
self.render_inlines(&body, w)?;
write!(w, "</a></h{level}></div>")?;
writeln!(w)
}
fn render_paragraph(&self, n: &ParagraphNode, w: &mut dyn Write) -> io::Result<()> {
@@ -357,16 +432,16 @@ impl HtmlRenderer {
}
fn render_preformatted(&self, n: &PreformattedNode, w: &mut dyn Write) -> io::Result<()> {
match &n.language {
Some(lang) => {
w.write_all(b"<pre><code class=\"language-")?;
write_escaped(lang, w)?;
w.write_all(b"\">")?;
}
None => w.write_all(b"<pre><code>")?,
// vimwiki drops the verbatim fence text into the `<pre>` tag as raw
// attributes (`{{{python` → `<pre python>`), with no inner `<code>`.
// We mirror that so highlighters wired for vimwiki output still apply.
if n.attrs.is_empty() {
w.write_all(b"<pre>")?;
} else {
write!(w, "<pre {}>", n.attrs)?;
}
write_escaped(&n.content, w)?;
w.write_all(b"</code></pre>\n")
w.write_all(b"</pre>\n")
}
fn render_math_block(&self, n: &MathBlockNode, w: &mut dyn Write) -> io::Result<()> {
@@ -683,16 +758,16 @@ impl HtmlRenderer {
// One class per keyword so stylesheets can colour them independently
// (e.g. red TODO/FIXME/XXX vs green DONE/FIXED/STARTED). TODO keeps the
// vimwiki `todo` class so stock vimwiki CSS still styles it.
let (label, class) = match n.keyword {
Keyword::Todo => ("TODO", "todo"),
Keyword::Done => ("DONE", "done"),
Keyword::Started => ("STARTED", "started"),
Keyword::Fixme => ("FIXME", "fixme"),
Keyword::Fixed => ("FIXED", "fixed"),
Keyword::Xxx => ("XXX", "xxx"),
Keyword::Stopped => ("STOPPED", "stopped"),
let class = match n.keyword {
Keyword::Todo => "todo",
Keyword::Done => "done",
Keyword::Started => "started",
Keyword::Fixme => "fixme",
Keyword::Fixed => "fixed",
Keyword::Xxx => "xxx",
Keyword::Stopped => "stopped",
};
write!(w, "<span class=\"{class}\">{label}</span>")
write!(w, "<span class=\"{class}\">{}</span>", n.keyword.label())
}
fn render_color(&self, n: &ColorNode, w: &mut dyn Write) -> io::Result<()> {
@@ -869,6 +944,51 @@ fn table_spans(table: &TableNode) -> Vec<Vec<CellLayout>> {
/// wiki pages become `path.html`; interwiki links land in sibling
/// directories; diary entries land under `diary/`; file/local schemes
/// use their literal path; anchor-only links collapse to a fragment.
/// Return a copy of `nodes` with every `Keyword` replaced by its literal text.
/// Used for heading content so a keyword in a title renders as plain text (with
/// the heading's own styling) rather than as an inline keyword badge.
fn flatten_keywords(nodes: &[InlineNode]) -> Vec<InlineNode> {
nodes
.iter()
.map(|n| match n {
InlineNode::Keyword(k) => InlineNode::Text(TextNode {
span: k.span,
content: k.keyword.label().to_string(),
}),
InlineNode::Bold(b) => InlineNode::Bold(BoldNode {
span: b.span,
children: flatten_keywords(&b.children),
}),
InlineNode::Italic(i) => InlineNode::Italic(ItalicNode {
span: i.span,
children: flatten_keywords(&i.children),
}),
InlineNode::BoldItalic(b) => InlineNode::BoldItalic(BoldItalicNode {
span: b.span,
children: flatten_keywords(&b.children),
}),
InlineNode::Strikethrough(s) => InlineNode::Strikethrough(StrikethroughNode {
span: s.span,
children: flatten_keywords(&s.children),
}),
InlineNode::Superscript(s) => InlineNode::Superscript(SuperscriptNode {
span: s.span,
children: flatten_keywords(&s.children),
}),
InlineNode::Subscript(s) => InlineNode::Subscript(SubscriptNode {
span: s.span,
children: flatten_keywords(&s.children),
}),
InlineNode::Color(c) => InlineNode::Color(ColorNode {
span: c.span,
color: c.color.clone(),
children: flatten_keywords(&c.children),
}),
other => other.clone(),
})
.collect()
}
pub fn default_link_resolver(target: &LinkTarget) -> String {
if matches!(target.kind, LinkKind::AnchorOnly) {
return match &target.anchor {
+39 -7
View File
@@ -81,6 +81,9 @@ pub enum VimwikiTokenKind {
PreformattedOpen {
language: Option<String>,
attrs: HashMap<String, String>,
/// Verbatim text after `{{{` (trailing whitespace trimmed), preserved
/// so the HTML renderer can reproduce vimwiki's raw `<pre …>` attrs.
raw: String,
},
PreformattedClose,
PreformattedLine(String),
@@ -421,7 +424,14 @@ impl<'src> LexState<'src> {
// pairs separated by spaces. Keep parsing forgiving.
let after = trimmed[3..].trim();
let (language, attrs) = parse_fence_attrs(after);
let kind = VimwikiTokenKind::PreformattedOpen { language, attrs };
// vimwiki keeps everything after `{{{` (minus trailing whitespace) and
// drops it verbatim into the `<pre>` tag, so capture it unparsed.
let raw = trimmed[3..].trim_end().to_string();
let kind = VimwikiTokenKind::PreformattedOpen {
language,
attrs,
raw,
};
let span = Span::new(self.pos_in_line(indent), self.line_end_pos(line));
self.push(kind, span);
self.mode = BlockMode::Preformatted;
@@ -516,10 +526,9 @@ impl<'src> LexState<'src> {
if level == 0 {
return false;
}
// Must be followed by at least one space.
if leading_ws + level >= bytes.len() || bytes[leading_ws + level] != b' ' {
return false;
}
// vimwiki accepts both spaced (`== H ==`) and spaceless (`==H==`)
// headings, so we don't require a space after the opening `=`s. The
// title is still trimmed of one optional space per side below.
// Trailing `=`s of the same level.
let trimmed_end = line.trim_end_matches([' ', '\t']);
@@ -865,8 +874,10 @@ impl<'src> LexState<'src> {
flush(self, &mut buf, &mut buf_start, abs_col);
self.emit(VimwikiTokenKind::WikiLinkOpen, abs_col, abs_col + 2);
i += 2;
// Lex until `]]` (absolute index into `slice`).
let close_abs_in_slice = after_open.find("]]").map(|c| i + c);
// Lex until the closing `]]`. A single `]` inside the body —
// e.g. a `[TICKET-1]` in the description — is literal text, so
// we balance inner `[ ]` rather than stopping at the first `]]`.
let close_abs_in_slice = find_wikilink_close(after_open).map(|c| i + c);
let inner_end = close_abs_in_slice.unwrap_or(slice.len());
self.lex_link_body(slice, i, line_col_start, inner_end, true);
i = inner_end;
@@ -1052,6 +1063,27 @@ impl<'src> LexState<'src> {
/// Parse the bit after `{{{` on a fence line. Format is forgiving:
/// `lang` (single word) followed by optional `key=value` pairs separated
/// by spaces.
/// Byte offset of the closing `]]` for a wikilink body that begins at the start
/// of `s` (just after the opening `[[`). Inner `[ ]` pairs are balanced so a
/// bracketed description like `[[page|Task [B-1]]]` keeps the `[B-1]` and closes
/// at the final `]]`. A stray single `]` (no matching `[`) is treated as literal
/// body text. Returns `None` if no closing `]]` is found.
fn find_wikilink_close(s: &str) -> Option<usize> {
let b = s.as_bytes();
let mut depth: u32 = 0;
let mut i = 0;
while i < b.len() {
match b[i] {
b'[' => depth += 1,
b']' if depth > 0 => depth -= 1, // closes an inner '['
b']' if i + 1 < b.len() && b[i + 1] == b']' => return Some(i),
_ => {}
}
i += 1;
}
None
}
fn parse_fence_attrs(s: &str) -> (Option<String>, HashMap<String, String>) {
let mut attrs = HashMap::new();
let mut language: Option<String> = None;
@@ -374,8 +374,8 @@ impl<'a> ParseState<'a> {
let open = self.advance().unwrap();
let span_start = open.span.start;
let mut span_end = open.span.end;
let language = match &open.kind {
K::PreformattedOpen { language, .. } => language.clone(),
let (language, attrs) = match &open.kind {
K::PreformattedOpen { language, raw, .. } => (language.clone(), raw.clone()),
_ => unreachable!(),
};
let mut content = String::new();
@@ -390,6 +390,7 @@ impl<'a> ParseState<'a> {
span: Span::new(span_start, span_end),
content,
language,
attrs,
});
}
K::PreformattedLine(s) => {
@@ -413,6 +414,7 @@ impl<'a> ParseState<'a> {
span: Span::new(span_start, span_end),
content,
language,
attrs,
})
}
+123 -10
View File
@@ -63,7 +63,11 @@ fn heading_levels() {
for level in 1..=6 {
let bars = "=".repeat(level);
let out = render(&format!("{bars} Title {bars}\n"));
assert!(out.starts_with(&format!("<h{level} id=\"Title\">Title</h{level}>")));
// vimwiki structure: <div id="hier"><h{l} id="flat" class="header">
// <a href="#hier">…</a></h{l}></div>. A lone heading has hier == flat.
assert!(out.starts_with(&format!(
"<div id=\"Title\"><h{level} id=\"Title\" class=\"header\"><a href=\"#Title\">Title</a></h{level}></div>"
)), "got: {out}");
}
}
@@ -73,6 +77,64 @@ fn centered_heading_gets_centered_class() {
assert!(out.contains("<h1 class=\"centered\" id=\"T\">T</h1>"));
}
#[test]
fn spaceless_heading_renders_as_heading() {
// `==Heading==` (no surrounding spaces) is a heading, like vimwiki — not a
// literal `<p>==Heading==</p>`.
let out = render("==Plain==\n");
assert!(
out.contains(
"<div id=\"Plain\"><h2 id=\"Plain\" class=\"header\">\
<a href=\"#Plain\">Plain</a></h2></div>"
),
"got: {out}"
);
// The spaceless + keyword combo from the report: a header, not a badge.
let out2 = render("==TODO==\n");
assert!(
out2.contains("<h2 id=\"TODO\" class=\"header\">"),
"got: {out2}"
);
assert!(!out2.contains("<p>"), "not a paragraph: {out2}");
}
#[test]
fn heading_keyword_renders_as_plain_text_not_badge() {
// `== TODO ==` is a section title, not an inline keyword: it must render as
// a normal header (with a correct, non-empty id), not a `<span class="todo">`
// badge. Regression for the exported header losing its header styling + id.
let out = render("== TODO ==\n");
assert!(
out.contains(
"<div id=\"TODO\"><h2 id=\"TODO\" class=\"header\">\
<a href=\"#TODO\">TODO</a></h2></div>"
),
"got: {out}"
);
assert!(!out.contains("class=\"todo\""), "no keyword badge: {out}");
// A keyword mid-title keeps its text in the id (no dropped word / double
// space) and still renders plainly.
let out2 = render("= My TODO list =\n");
assert!(out2.contains("id=\"My TODO list\""), "got: {out2}");
assert!(!out2.contains("class=\"todo\""), "got: {out2}");
}
#[test]
fn wikilink_description_keeps_inner_brackets() {
// A `]` inside a wikilink description (e.g. a `[TICKET-1]`) must not close
// the link early. Regression for descriptions with brackets being mangled.
let out = render("[[page|Task [B-1]]]\n");
assert!(
out.contains("<a href=\"page.html\">Task [B-1]</a>"),
"got: {out}"
);
// Two bracketed links on one line both resolve.
let out2 = render("[[a|X [1]]] and [[b|Y [2]]]\n");
assert!(out2.contains("<a href=\"a.html\">X [1]</a>"), "got: {out2}");
assert!(out2.contains("<a href=\"b.html\">Y [2]</a>"), "got: {out2}");
}
#[test]
fn paragraph_wraps_inline_content() {
let out = render("Hello world\n");
@@ -94,11 +156,26 @@ fn blockquote_lines_become_paragraphs() {
}
#[test]
fn preformatted_block_emits_pre_code_with_language_class() {
fn preformatted_block_emits_pre_with_raw_fence_attrs() {
// vimwiki drops the verbatim fence text into the <pre> tag (`{{{rust` →
// `<pre rust>`), with no inner <code> wrapper.
let out = render("{{{rust\nfn main() {}\n}}}\n");
assert!(out.contains("<pre><code class=\"language-rust\">"));
assert!(out.contains("<pre rust>"), "got: {out}");
assert!(out.contains("fn main() {}"));
assert!(out.contains("</code></pre>"));
assert!(out.contains("</pre>"));
assert!(!out.contains("<code"), "no inner code element: {out}");
}
#[test]
fn preformatted_block_without_attrs_is_bare_pre() {
let out = render("{{{\nplain\n}}}\n");
assert!(out.contains("<pre>plain"), "got: {out}");
}
#[test]
fn preformatted_block_keeps_full_attr_string() {
let out = render("{{{class=\"brush: python\"\nx\n}}}\n");
assert!(out.contains("<pre class=\"brush: python\">"), "got: {out}");
}
#[test]
@@ -244,7 +321,10 @@ fn heading_id_matches_anchor_link_href() {
// for "exported HTML anchor links don't work".
let out = render("= My Section =\n\n[[#My Section]]\n");
assert!(
out.contains("<h1 id=\"My Section\">My Section</h1>"),
out.contains(
"<div id=\"My Section\"><h1 id=\"My Section\" class=\"header\">\
<a href=\"#My Section\">My Section</a></h1></div>"
),
"heading id: {out}"
);
assert!(
@@ -253,6 +333,39 @@ fn heading_id_matches_anchor_link_href() {
);
}
#[test]
fn toc_header_heading_wrapped_in_div_toc() {
// The TOC section heading gets a `<div class="toc">` wrapper (vimwiki
// scheme) so `.toc` stylesheet rules apply. The class is on the div, not
// the heading.
let doc = VimwikiSyntax::new().parse("== Contents ==\n");
let out = HtmlRenderer::new()
.with_toc_header("Contents")
.render_to_string(&doc)
.unwrap();
assert!(
out.contains("<div class=\"toc\"><h2 id=\"Contents\">Contents</h2></div>"),
"got: {out}"
);
}
#[test]
fn non_toc_heading_not_wrapped() {
let doc = VimwikiSyntax::new().parse("== Intro ==\n");
let out = HtmlRenderer::new()
.with_toc_header("Contents")
.render_to_string(&doc)
.unwrap();
assert!(
out.contains(
"<div id=\"Intro\"><h2 id=\"Intro\" class=\"header\">\
<a href=\"#Intro\">Intro</a></h2></div>"
),
"got: {out}"
);
assert!(!out.contains("class=\"toc\""), "got: {out}");
}
#[test]
fn interwiki_numbered_link() {
let out = render("[[wiki1:Page]]\n");
@@ -315,7 +428,7 @@ fn template_substitutes_content_and_title() {
);
let out = renderer.render_to_string(&doc).unwrap();
assert!(out.contains("<title>My Page</title>"));
assert!(out.contains("<body><h1 id=\"Hello\">Hello</h1>"));
assert!(out.contains("<body><div id=\"Hello\"><h1 id=\"Hello\" class=\"header\"><a href=\"#Hello\">Hello</a></h1></div>"));
}
#[test]
@@ -324,7 +437,7 @@ fn template_with_missing_title_substitutes_empty_string() {
let renderer = HtmlRenderer::new().with_template("<title>{{title}}</title>{{content}}");
let out = renderer.render_to_string(&doc).unwrap();
assert!(out.contains("<title></title>"));
assert!(out.contains("<h1 id=\"Heading\">Heading</h1>"));
assert!(out.contains("<div id=\"Heading\"><h1 id=\"Heading\" class=\"header\"><a href=\"#Heading\">Heading</a></h1></div>"));
}
#[test]
@@ -340,7 +453,7 @@ fn template_substitutes_vimwiki_percent_placeholders() {
let out = renderer.render_to_string(&doc).unwrap();
assert!(out.contains("<title>My Page</title>"), "title: {out}");
assert!(
out.contains("<body><h1 id=\"Hello\">Hello</h1>"),
out.contains("<body><div id=\"Hello\"><h1 id=\"Hello\" class=\"header\"><a href=\"#Hello\">Hello</a></h1></div>"),
"content: {out}"
);
assert!(out.contains("href=\"../style.css\""), "root_path: {out}");
@@ -434,7 +547,7 @@ fn x() {}
let out = HtmlRenderer::new().render_to_string(&doc).unwrap();
// A few sanity checks; the full output is exercised piece-by-piece above.
for needle in [
"<h1 id=\"Heading\">Heading</h1>",
"<div id=\"Heading\"><h1 id=\"Heading\" class=\"header\"><a href=\"#Heading\">Heading</a></h1></div>",
"<strong>bold</strong>",
"<em>italic</em>",
"<code>code</code>",
@@ -442,7 +555,7 @@ fn x() {}
"<blockquote>",
"<hr>",
"<table>",
"<pre><code class=\"language-rust\">",
"<pre rust>",
] {
assert!(
out.contains(needle),
+35 -1
View File
@@ -85,6 +85,32 @@ fn centered_heading_is_marked_centered() {
assert!(matches!(lexed[2], HeadingClose));
}
#[test]
fn spaceless_heading_is_a_heading() {
// vimwiki accepts `==Heading==` with no spaces around the text.
let lexed = lex("==Plain==\n");
assert_eq!(
lexed,
vec![
HeadingOpen {
level: 2,
centered: false
},
text("Plain"),
HeadingClose,
Newline,
]
);
}
#[test]
fn marker_only_or_unbalanced_lines_are_not_headings() {
// No title between markers, and trailing `=` not at line end → plain text.
assert!(!matches!(lex("======\n")[0], HeadingOpen { .. }));
assert!(!matches!(lex("==> arrow\n")[0], HeadingOpen { .. }));
assert!(!matches!(lex("==a==b\n")[0], HeadingOpen { .. }));
}
#[test]
fn heading_with_inline_bold() {
let lexed = lex("== Hello *world* ==\n");
@@ -179,7 +205,13 @@ fn preformatted_block_parses_language() {
#[test]
fn preformatted_block_parses_attrs() {
let lexed = lex("{{{rust class=\"hl\" key=val\nx\n}}}\n");
let PreformattedOpen { language, attrs } = &lexed[0] else {
let PreformattedOpen {
language,
attrs,
raw,
..
} = &lexed[0]
else {
panic!("expected PreformattedOpen");
};
assert_eq!(language.as_deref(), Some("rust"));
@@ -187,6 +219,8 @@ fn preformatted_block_parses_attrs() {
expected.insert("class".into(), "hl".into());
expected.insert("key".into(), "val".into());
assert_eq!(attrs, &expected);
// The verbatim fence text is preserved for the HTML renderer.
assert_eq!(raw, "rust class=\"hl\" key=val");
}
// ===== Math block =====
+3 -2
View File
@@ -190,8 +190,9 @@ fn file_tags_are_deduped_in_metadata() {
fn renderer_emits_div_with_tag_spans() {
let html = render(":todo:done:\n");
assert!(html.contains("<div class=\"tags\">"));
assert!(html.contains("<span class=\"tag\" id=\"tag-todo\">todo</span>"));
assert!(html.contains("<span class=\"tag\" id=\"tag-done\">done</span>"));
// Bare tag name as the id (vimwiki parity), so `[[Page#todo]]` resolves.
assert!(html.contains("<span class=\"tag\" id=\"todo\">todo</span>"));
assert!(html.contains("<span class=\"tag\" id=\"done\">done</span>"));
}
#[test]
+1 -1
View File
@@ -17,5 +17,5 @@ name = "nuwiki-ls"
path = "src/main.rs"
[dependencies]
nuwiki-lsp = { path = "../nuwiki-lsp", version = "0.1.0" }
nuwiki-lsp = { path = "../nuwiki-lsp", version = "0.4.2" }
tokio = { version = "1", features = ["macros", "rt-multi-thread", "io-std"] }
+1 -1
View File
@@ -13,7 +13,7 @@ homepage.workspace = true
workspace = true
[dependencies]
nuwiki-core = { path = "../nuwiki-core", version = "0.1.0" }
nuwiki-core = { path = "../nuwiki-core", version = "0.4.2" }
tower-lsp = "0.20"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "io-std", "sync", "fs"] }
dashmap = "6"
+183 -26
View File
@@ -41,6 +41,7 @@ pub const COMMANDS: &[&str] = &[
"nuwiki.heading.removeLevel",
"nuwiki.toc.generate",
"nuwiki.links.generate",
"nuwiki.links.generateForPath",
"nuwiki.workspace.checkLinks",
"nuwiki.workspace.findOrphans",
"nuwiki.diary.openToday",
@@ -117,6 +118,9 @@ pub(crate) async fn execute(
"nuwiki.links.generate" => {
links_generate(backend, args).map(|o| o.map(CommandOutcome::Edit))
}
"nuwiki.links.generateForPath" => {
links_generate_for_path(backend, args).map(|o| o.map(CommandOutcome::Edit))
}
"nuwiki.workspace.checkLinks" => {
workspace_check_links(backend, args).map(|o| o.map(CommandOutcome::Value))
}
@@ -304,6 +308,24 @@ fn parse_uri_arg(args: Vec<Value>) -> Result<UriArg, String> {
serde_json::from_value(raw).map_err(|e| format!("invalid args: {e}"))
}
/// Parse `{ uri, line? }` for the section generators (TOC / links / tags). The
/// optional 0-based `line` is the cursor line the client sends, where a fresh
/// section is inserted.
fn parse_uri_line_arg(args: Vec<Value>) -> Result<(Url, Option<u32>), String> {
#[derive(Deserialize)]
struct A {
uri: Url,
#[serde(default)]
line: Option<u32>,
}
let raw = args
.into_iter()
.next()
.ok_or_else(|| "missing { uri } argument".to_string())?;
let a: A = serde_json::from_value(raw).map_err(|e| format!("invalid args: {e}"))?;
Ok((a.uri, a.line))
}
#[derive(Deserialize, Default)]
struct OptionalUriArg {
#[serde(default)]
@@ -323,13 +345,13 @@ fn parse_optional_uri_arg(args: Vec<Value>) -> Result<OptionalUriArg, String> {
}
fn toc_generate(backend: &Backend, args: Vec<Value>) -> Result<Option<WorkspaceEdit>, String> {
let p = parse_uri_arg(args)?;
let doc = match backend.documents.get(&p.uri) {
let (uri, cursor_line) = parse_uri_line_arg(args)?;
let doc = match backend.documents.get(&uri) {
Some(d) => d,
None => return Ok(None),
};
let utf8 = backend.use_utf8.load(Ordering::Relaxed);
let wiki = backend.wiki_for_uri(&p.uri);
let wiki = backend.wiki_for_uri(&uri);
let (header, level) = wiki
.as_ref()
.map(|w| (w.config.toc_header.clone(), w.config.toc_header_level))
@@ -342,27 +364,73 @@ fn toc_generate(backend: &Backend, args: Vec<Value>) -> Result<Option<WorkspaceE
Ok(ops::toc_edit(
&doc.text,
&doc.ast,
&p.uri,
&uri,
utf8,
&header,
level,
margin,
link_format,
cursor_line,
))
}
fn links_generate(backend: &Backend, args: Vec<Value>) -> Result<Option<WorkspaceEdit>, String> {
let p = parse_uri_arg(args)?;
let doc = match backend.documents.get(&p.uri) {
let (uri, cursor_line) = parse_uri_line_arg(args)?;
links_generate_scoped(backend, &uri, cursor_line, None)
}
/// `:VimwikiGenerateLinks <path>` — scope the generated links to a subtree.
fn links_generate_for_path(
backend: &Backend,
args: Vec<Value>,
) -> Result<Option<WorkspaceEdit>, String> {
#[derive(Deserialize)]
struct Args {
uri: Url,
path: String,
#[serde(default)]
line: Option<u32>,
}
let raw = args.into_iter().next().ok_or_else(|| {
"nuwiki.links.generateForPath: missing { uri, path } argument".to_string()
})?;
let parsed: Args = serde_json::from_value(raw)
.map_err(|e| format!("nuwiki.links.generateForPath: invalid args — {e}"))?;
links_generate_scoped(backend, &parsed.uri, parsed.line, Some(&parsed.path))
}
/// True when page `name` falls within `scope`. A scope containing `*`/`?` is a
/// glob (upstream's pattern arg); otherwise it's a directory subtree — the page
/// itself or anything under `<scope>/`. Page names are wiki-root-relative with
/// `/` separators (e.g. `projects/api`).
fn page_in_scope(name: &str, scope: &str) -> bool {
let scope = scope.trim_matches('/');
if scope.is_empty() {
return true;
}
if scope.contains('*') || scope.contains('?') {
crate::export::glob_match(scope, name)
} else {
name == scope || name.starts_with(&format!("{scope}/"))
}
}
fn links_generate_scoped(
backend: &Backend,
uri: &Url,
cursor_line: Option<u32>,
scope: Option<&str>,
) -> Result<Option<WorkspaceEdit>, String> {
let doc = match backend.documents.get(uri) {
Some(d) => d,
None => return Ok(None),
};
let utf8 = backend.use_utf8.load(Ordering::Relaxed);
let wiki = match backend.wiki_for_uri(&p.uri) {
let wiki = match backend.wiki_for_uri(uri) {
Some(w) => w,
None => return Ok(None),
};
let current_page = crate::index::page_name_from_uri(&p.uri, Some(&wiki.config.root));
let current_page = crate::index::page_name_from_uri(uri, Some(&wiki.config.root));
let (pages, captions) = {
let idx = wiki
.index
@@ -372,12 +440,20 @@ fn links_generate(backend: &Backend, args: Vec<Value>) -> Result<Option<Workspac
.config
.generated_links_caption
.then(|| ops::page_captions(&idx));
(idx.page_names(), captions)
let names: Vec<String> = match scope {
Some(s) => idx
.page_names()
.into_iter()
.filter(|n| page_in_scope(n, s))
.collect(),
None => idx.page_names(),
};
(names, captions)
};
Ok(ops::links_edit(
&doc.text,
&doc.ast,
&p.uri,
uri,
&current_page,
&pages,
utf8,
@@ -385,6 +461,7 @@ fn links_generate(backend: &Backend, args: Vec<Value>) -> Result<Option<Workspac
wiki.config.links_header_level,
wiki.config.list_margin.max(0) as usize,
captions.as_ref(),
cursor_line,
))
}
@@ -621,6 +698,9 @@ fn tags_generate_links(
uri: Url,
#[serde(default)]
tag: Option<String>,
/// 0-based cursor line; a fresh tags section is inserted here.
#[serde(default)]
line: Option<u32>,
}
let raw = args
.into_iter()
@@ -653,6 +733,7 @@ fn tags_generate_links(
&wiki.config.tags_header,
wiki.config.tags_header_level,
wiki.config.list_margin.max(0) as usize,
parsed.line,
))
}
@@ -1359,6 +1440,32 @@ fn eof_position(text: &str) -> LspPosition {
}
}
/// Decide where to insert a generated section (TOC / generated links / tags
/// index) and the text block to write. When the client passed a cursor
/// `insert_line`, insert at that line (clamped to the document; a leading blank
/// keeps the heading off the preceding line, except at the top of the file).
/// Otherwise fall back to `fallback` — top of file for the TOC, end of file for
/// the links/tags sections.
fn section_insert(
text: &str,
insert_line: Option<u32>,
fallback: LspPosition,
new_text: &str,
) -> (LspPosition, String) {
match insert_line {
Some(l) => {
let line = l.min(text.matches('\n').count() as u32);
let block = if line == 0 {
format!("{new_text}\n")
} else {
format!("\n{new_text}\n")
};
(LspPosition { line, character: 0 }, block)
}
None => (fallback, format!("{new_text}\n")),
}
}
// ===== Pure operations =====
pub mod ops {
@@ -2060,6 +2167,11 @@ pub mod ops {
/// Produce a `WorkspaceEdit` that replaces or inserts the TOC for the
/// given document. Returns `None` if the document has no headings at
/// all (other than possibly a pre-existing TOC heading).
///
/// When there's no existing TOC section, a fresh one is inserted at
/// `insert_line` (0-based) — the cursor line passed by the client — or at
/// the top of the file when `None`. An existing TOC is always replaced in
/// place, regardless of the cursor.
#[allow(clippy::too_many_arguments)]
pub fn toc_edit(
text: &str,
@@ -2070,6 +2182,7 @@ pub mod ops {
level: u8,
margin: usize,
link_format: u8,
insert_line: Option<u32>,
) -> Option<WorkspaceEdit> {
let items = collect_toc_items(ast, heading);
if items.is_empty() {
@@ -2087,17 +2200,13 @@ pub mod ops {
b.edit(uri.clone(), text_edit_replace(span, new_text, text, utf8));
}
None => {
let with_sep = format!("{new_text}\n");
b.edit(
uri.clone(),
text_edit_insert(
LspPosition {
// Fresh TOC: at the cursor line, else the top of the file.
let top = LspPosition {
line: 0,
character: 0,
},
with_sep,
),
);
};
let (pos, block) = section_insert(text, insert_line, top, &new_text);
b.edit(uri.clone(), text_edit_insert(pos, block));
}
}
Some(b.build())
@@ -2119,7 +2228,19 @@ pub mod ops {
link_format: u8,
) -> Option<WorkspaceEdit> {
find_section_range(ast, heading)?;
toc_edit(text, ast, uri, utf8, heading, level, margin, link_format)
// Rebuild-on-save only ever replaces an existing TOC in place, so the
// insert line is irrelevant.
toc_edit(
text,
ast,
uri,
utf8,
heading,
level,
margin,
link_format,
None,
)
}
/// Produce a `WorkspaceEdit` that replaces or inserts the
@@ -2136,6 +2257,7 @@ pub mod ops {
level: u8,
margin: usize,
captions: Option<&std::collections::BTreeMap<String, String>>,
insert_line: Option<u32>,
) -> Option<WorkspaceEdit> {
if all_pages.is_empty() {
return None;
@@ -2155,9 +2277,9 @@ pub mod ops {
b.edit(uri.clone(), text_edit_replace(span, new_text, text, utf8));
}
None => {
let with_sep = format!("{new_text}\n");
let insert_pos = eof_position(text);
b.edit(uri.clone(), text_edit_insert(insert_pos, with_sep));
// Fresh section: at the cursor line, else the end of the file.
let (pos, block) = section_insert(text, insert_line, eof_position(text), &new_text);
b.edit(uri.clone(), text_edit_insert(pos, block));
}
}
Some(b.build())
@@ -2180,6 +2302,7 @@ pub mod ops {
captions: Option<&std::collections::BTreeMap<String, String>>,
) -> Option<WorkspaceEdit> {
find_section_range(ast, heading)?;
// Rebuild-on-save only ever replaces an existing section in place.
links_edit(
text,
ast,
@@ -2191,6 +2314,7 @@ pub mod ops {
level,
margin,
captions,
None,
)
}
@@ -3413,6 +3537,7 @@ pub mod ops {
header: &str,
level: u8,
margin: usize,
insert_line: Option<u32>,
) -> Option<WorkspaceEdit> {
let body = build_tag_links_text(pages_by_tag, tag, header, level, margin)?;
let heading = tag_section_heading(tag, header);
@@ -3423,9 +3548,9 @@ pub mod ops {
b.edit(uri.clone(), text_edit_replace(span, body, text, utf8));
}
None => {
let with_sep = format!("{body}\n");
let insert_pos = eof_position(text);
b.edit(uri.clone(), text_edit_insert(insert_pos, with_sep));
// Fresh section: at the cursor line, else the end of the file.
let (pos, block) = section_insert(text, insert_line, eof_position(text), &body);
b.edit(uri.clone(), text_edit_insert(pos, block));
}
}
Some(b.build())
@@ -3446,6 +3571,7 @@ pub mod ops {
margin: usize,
) -> Option<WorkspaceEdit> {
find_section_range(ast, header)?;
// Rebuild-on-save only ever replaces an existing section in place.
tag_links_edit(
text,
ast,
@@ -3456,6 +3582,7 @@ pub mod ops {
header,
level,
margin,
None,
)
}
@@ -3871,3 +3998,33 @@ pub mod export_ops {
out
}
}
#[cfg(test)]
mod scope_tests {
use super::page_in_scope;
#[test]
fn empty_scope_matches_everything() {
assert!(page_in_scope("anything", ""));
assert!(page_in_scope("a/b/c", "/"));
}
#[test]
fn subtree_matches_self_and_descendants() {
assert!(page_in_scope("projects", "projects"));
assert!(page_in_scope("projects/api", "projects"));
assert!(page_in_scope("projects/api/v2", "projects"));
// Not a sibling that merely shares a prefix.
assert!(!page_in_scope("projectsX", "projects"));
assert!(!page_in_scope("Home", "projects"));
// Trailing slash on the scope is tolerated.
assert!(page_in_scope("projects/api", "projects/"));
}
#[test]
fn glob_scope_uses_pattern_match() {
assert!(page_in_scope("diary/2026-05-30", "diary/*"));
assert!(page_in_scope("notes-2026", "notes-*"));
assert!(!page_in_scope("Home", "diary/*"));
}
}
+40 -16
View File
@@ -176,6 +176,10 @@ pub struct HtmlConfig {
/// vimwiki `html_header_numbering_sym`: symbol appended after the
/// section number (e.g. `.` → `1.2. Heading`). Empty by default.
pub html_header_numbering_sym: String,
/// vimwiki `toc_header`: the heading text that identifies the TOC
/// section. When a heading matches this value (case-insensitive), it
/// gets `class="toc"` in HTML export.
pub toc_header: String,
/// vimwiki `rss_name`: filename of the generated RSS feed (default
/// `rss.xml`), relative to `html_path`.
pub rss_name: String,
@@ -224,6 +228,7 @@ impl Default for HtmlConfig {
base_url: String::new(),
html_header_numbering: 0,
html_header_numbering_sym: String::new(),
toc_header: default_toc_header(),
rss_name: "rss.xml".into(),
rss_max_items: 10,
valid_html_tags: default_valid_html_tags(),
@@ -634,14 +639,11 @@ impl Config {
let wikis = if let Some(list) = raw.wikis {
list.into_iter().map(WikiConfig::from).collect()
} else if let Some(root) = raw.wiki_root.as_deref().and_then(expand_path) {
let mut wc = WikiConfig::from_root(root);
if let Some(ext) = raw.file_extension {
wc.file_extension = ext;
}
if let Some(s) = raw.syntax {
wc.syntax = s;
}
} else if let Some(wc) = params
.initialization_options
.as_ref()
.and_then(single_wiki_from_value)
{
vec![wc]
} else if let Some(folder) = first_workspace_folder(params) {
vec![WikiConfig::from_root(folder)]
@@ -682,14 +684,7 @@ impl Config {
};
if let Some(list) = raw.wikis {
self.wikis = list.into_iter().map(WikiConfig::from).collect();
} else if let Some(root) = raw.wiki_root.as_deref().and_then(expand_path) {
let mut wc = WikiConfig::from_root(root);
if let Some(ext) = raw.file_extension {
wc.file_extension = ext;
}
if let Some(s) = raw.syntax {
wc.syntax = s;
}
} else if let Some(wc) = single_wiki_from_value(inner) {
self.wikis = vec![wc];
}
// Only touch diagnostic settings when the payload carries them, so a
@@ -954,6 +949,9 @@ impl From<RawWiki> for WikiConfig {
if let Some(s) = r.html_header_numbering_sym {
html.html_header_numbering_sym = s;
}
if let Some(ref s) = r.toc_header {
html.toc_header = s.clone();
}
if let Some(s) = r.rss_name {
html.rss_name = s;
}
@@ -1034,6 +1032,32 @@ impl From<RawWiki> for WikiConfig {
}
}
/// Build the single-wiki [`WikiConfig`] from the flat top-level options object
/// (single-wiki shorthand: a `wiki_root` plus any per-wiki keys set alongside
/// it). Returns `None` when there's no expandable `wiki_root` (so callers fall
/// through to the workspace-folder default) or a `wikis` list is present.
///
/// Re-uses the full [`RawWiki`] schema so **every** per-wiki key set at the top
/// level — `toc_header`/`toc_header_level`, `links_header`, `html_path`,
/// `auto_export`, … — is honoured, not just `file_extension`/`syntax`. The
/// shorthand keys on `wiki_root`; `RawWiki` keys on `root`, so we inject it.
fn single_wiki_from_value(value: &serde_json::Value) -> Option<WikiConfig> {
let obj = value.as_object()?;
if obj.contains_key("wikis") {
return None;
}
let wiki_root = obj.get("wiki_root").and_then(|v| v.as_str())?;
// Bail (→ workspace-folder fallback) when the root can't be expanded.
expand_path(wiki_root)?;
let mut wiki_obj = obj.clone();
wiki_obj.insert(
"root".to_string(),
serde_json::Value::String(wiki_root.to_string()),
);
let raw: RawWiki = serde_json::from_value(serde_json::Value::Object(wiki_obj)).ok()?;
Some(WikiConfig::from(raw))
}
fn expand_path(s: &str) -> Option<PathBuf> {
let trimmed = s.trim();
if trimmed.is_empty() {
+212 -15
View File
@@ -227,17 +227,32 @@ pub fn render_page_html(
}
let mut r = HtmlRenderer::new();
if let Some(ext) = wiki_extension.filter(|e| !e.trim_start_matches('.').is_empty()) {
{
// Same-page anchor links (`[[#Section]]`) resolve to the *current*
// page's html file plus the fragment (`index.html#Section`), matching
// vimwiki — rather than a bare `#Section`.
let current_html = format!("{}.html", name.rsplit('/').next().unwrap_or(name));
// Strip the wiki extension from page links before the default resolver
// turns them into `.html` URLs, so `[[todo.wiki]]` → `todo.html` rather
// than `todo.wiki.html`. Only wiki/interwiki targets are touched;
// file:/local: paths keep their literal extension.
let ext = ext.to_string();
let ext = wiki_extension
.filter(|e| !e.trim_start_matches('.').is_empty())
.map(|e| e.to_string());
r = r.with_link_resolver(move |target| {
if matches!(target.kind, LinkKind::AnchorOnly) {
return match &target.anchor {
Some(a) => format!("{current_html}#{a}"),
None => current_html.clone(),
};
}
let mut t = target.clone();
if let Some(ext) = &ext {
if matches!(t.kind, LinkKind::Wiki | LinkKind::Interwiki) {
if let Some(p) = t.path.take() {
t.path = Some(crate::index::strip_wiki_extension(&p, Some(&ext)).to_string());
t.path =
Some(crate::index::strip_wiki_extension(&p, Some(ext)).to_string());
}
}
}
nuwiki_core::render::html::default_link_resolver(&t)
@@ -259,6 +274,7 @@ pub fn render_page_html(
cfg.html_header_numbering_sym.clone(),
);
}
r = r.with_toc_header(&cfg.toc_header);
if !cfg.valid_html_tags.is_empty() {
r = r.with_valid_html_tags(cfg.valid_html_tags.clone());
}
@@ -328,7 +344,7 @@ fn escape_html(s: &str) -> String {
/// Minimal glob match for `exclude_files`. Handles `*`, `**`, `?` and
/// literal characters. No character classes — those aren't needed for
/// "exclude foo/*.tmp" style patterns.
fn glob_match(pattern: &str, text: &str) -> bool {
pub(crate) fn glob_match(pattern: &str, text: &str) -> bool {
glob_impl(pattern.as_bytes(), text.as_bytes())
}
@@ -381,14 +397,195 @@ pub fn css_path(cfg: &HtmlConfig) -> PathBuf {
cfg.html_path.join(&cfg.css_name)
}
/// Minimal default CSS. Used by `nuwiki.export.*` commands when no CSS
/// file exists yet at [`css_path`]. Deliberately tiny — users are
/// expected to replace it with their own.
pub const DEFAULT_CSS: &str =
"body{font-family:sans-serif;max-width:46em;margin:2em auto;padding:0 1em;line-height:1.55}\
h1,h2,h3,h4,h5,h6{font-family:serif;line-height:1.2}\
pre{background:#f4f4f4;padding:.5em;overflow:auto}\
code{background:#f4f4f4;padding:.1em .25em;border-radius:3px}\
table{border-collapse:collapse}\
table td,table th{border:1px solid #ccc;padding:.25em .5em}\
ul.toc{padding-left:1.5em}\n";
/// Default CSS written by `nuwiki.export.*` commands when no CSS file exists
/// yet at [`css_path`]. This is vimwiki's stock `style.css` verbatim (MIT
/// licensed) so exports look identical to upstream out of the box and the
/// `.header` / `.tag` / `.toc` / `done*` classes our HTML emits are styled.
pub const DEFAULT_CSS: &str = r#"body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;;
margin: 2em 4em 2em 4em;
font-size: 120%;
line-height: 130%;
}
h1, h2, h3, h4, h5, h6 {
font-weight: bold;
line-height:100%;
margin-top: 1.5em;
margin-bottom: 0.5em;
}
h1 {font-size: 2em; color: #000000;}
h2 {font-size: 1.8em; color: #404040;}
h3 {font-size: 1.6em; color: #707070;}
h4 {font-size: 1.4em; color: #909090;}
h5 {font-size: 1.2em; color: #989898;}
h6 {font-size: 1em; color: #9c9c9c;}
p, pre, blockquote, table, ul, ol, dl {
margin-top: 1em;
margin-bottom: 1em;
}
ul ul, ul ol, ol ol, ol ul {
margin-top: 0.5em;
margin-bottom: 0.5em;
}
li { margin: 0.3em auto; }
ul {
margin-left: 2em;
padding-left: 0;
}
dt { font-weight: bold; }
img { border: none; }
pre {
border-left: 5px solid #dcdcdc;
background-color: #f5f5f5;
padding-left: 1em;
font-family: Monaco, "Courier New", "DejaVu Sans Mono", "Bitstream Vera Sans Mono", monospace;
font-size: 0.8em;
border-radius: 6px;
}
p > a {
color: white;
text-decoration: none;
font-size: 0.7em;
padding: 3px 6px;
border-radius: 3px;
background-color: #1e90ff;
text-transform: uppercase;
font-weight: bold;
}
p > a:hover {
color: #dcdcdc;
background-color: #484848;
}
li > a {
color: #1e90ff;
font-weight: bold;
text-decoration: none;
}
li > a:hover { color: #ff4500; }
blockquote {
color: #686868;
font-size: 0.8em;
line-height: 120%;
padding: 0.8em;
border-left: 5px solid #dcdcdc;
}
th, td {
border: 1px solid #ccc;
padding: 0.3em;
}
th { background-color: #f0f0f0; }
hr {
border: none;
border-top: 1px solid #ccc;
width: 100%;
}
del {
text-decoration: line-through;
color: #777777;
}
.toc li { list-style-type: none; }
.todo {
font-weight: bold;
background-color: #ff4500 ;
color: white;
font-size: 0.8em;
padding: 3px 6px;
border-radius: 3px;
}
.justleft { text-align: left; }
.justright { text-align: right; }
.justcenter { text-align: center; }
.center {
margin-left: auto;
margin-right: auto;
}
.tag {
background-color: #eeeeee;
font-family: monospace;
padding: 2px;
}
.header a {
text-decoration: none;
color: inherit;
}
/* classes for items of todo lists */
.rejected {
/* list-style: none; */
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA8AAAAPCAMAAAAMCGV4AAAACXBIWXMAAADFAAAAxQEdzbqoAAAAB3RJTUUH4QgEFhAtuWgv9wAAAPZQTFRFmpqam5iYnJaWnJeXnpSUn5OTopCQpoqKpouLp4iIqIiIrYCAt3V1vW1tv2xsmZmZmpeXnpKS/x4e/x8f/yAg/yIi/yQk/yUl/yYm/ygo/ykp/yws/zAw/zIy/zMz/zQ0/zU1/zY2/zw8/0BA/0ZG/0pK/1FR/1JS/1NT/1RU/1VV/1ZW/1dX/1pa/15e/19f/2Zm/2lp/21t/25u/3R0/3p6/4CA/4GB/4SE/4iI/46O/4+P/52d/6am/6ur/66u/7Oz/7S0/7e3/87O/9fX/9zc/93d/+Dg/+vr/+3t/+/v//Dw//Ly//X1//f3//n5//z8////gzaKowAAAA90Uk5T/Pz8/Pz8/Pz8/Pz8/f39ppQKWQAAAAFiS0dEEnu8bAAAAACuSURBVAhbPY9ZF4FQFEZPSKbIMmWep4gMGTKLkIv6/3/GPbfF97b3w17rA0kQOPgvAeHW6uJ6+5h7HqLdwowgOzejXRXBdx6UdSquml4xuOMBHHNU0clTzeSUA6EhF8V8kqroluMiU6HKcuf4phGPr1o2q9kYZWwNq1qfRRmTaXpqsyjj17KkWCxKBUBgXWueHIyiAIg18gsse4KHkLF5IKIY10WQgv7fOy4ST34BRiopZ8WLNrgAAAAASUVORK5CYII=);
background-repeat: no-repeat;
background-position: 0 .2em;
padding-left: 1.5em;
}
.done0 {
/* list-style: none; */
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA8AAAAPCAYAAAA71pVKAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAAxQAAAMUBHc26qAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAA7SURBVCiR7dMxEgAgCANBI3yVRzF5KxNbW6wsuH7LQ2YKQK1mkswBVERYF5Os3UV3gwd/jF2SkXy66gAZkxS6BniubAAAAABJRU5ErkJggg==);
background-repeat: no-repeat;
background-position: 0 .2em;
padding-left: 1.5em;
}
.done1 {
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA8AAAAPCAYAAAA71pVKAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAAxQAAAMUBHc26qAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAABtSURBVCiR1ZO7DYAwDER9BDmTeZQMFXmUbGYpOjrEryA0wOvO8itOslFrJYAug5BMM4BeSkmjsrv3aVTa8p48Xw1JSkSsWVUFwD05IqS1tmYzk5zzae9jnVVVzGyXb8sALjse+euRkEzu/uirFomVIdDGOLjuAAAAAElFTkSuQmCC);
background-repeat: no-repeat;
background-position: 0 .15em;
padding-left: 1.5em;
}
.done2 {
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA8AAAAPCAYAAAA71pVKAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAAxQAAAMUBHc26qAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAB1SURBVCiRzdO5DcAgDAVQGxjAYgTvxlDIu1FTIRYAp8qlFISkSH7l5kk+ZIwxKiI2mIyqWoeILYRgZ7GINDOLjnmF3VqklKCUMgTee2DmM661Qs55iI3Zm/1u5h9sm4ig9z4ERHTFzLyd4G4+nFlVrYg8+qoF/c0kdpeMsmcAAAAASUVORK5CYII=);
background-repeat: no-repeat;
background-position: 0 .15em;
padding-left: 1.5em;
}
.done3 {
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA8AAAAPCAYAAAA71pVKAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAAxQAAAMUBHc26qAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAABoSURBVCiR7dOxDcAgDATA/0DtUdiKoZC3YhLkHjkVKF3idJHiztKfvrHZWnOSE8Fx95RJzlprimJVnXktvXeY2S0SEZRSAAAbmxnGGKH2I5T+8VfxPhIReQSuuY3XyYWa3T2p6quvOgGrvSFGlewuUAAAAABJRU5ErkJggg==);
background-repeat: no-repeat;
background-position: 0 .15em;
padding-left: 1.5em;
}
.done4 {
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABIAAAAQCAYAAAAbBi9cAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAAzgAAAM4BlP6ToAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAIISURBVDiNnZQ9SFtRFMd/773kpTaGJoQk1im4VDpWQcTNODhkFBcVTCNCF0NWyeDiIIiCm82QoIMIUkHUxcFBg1SEQoZszSat6cdTn1qNue92CMbEr9Of5+vd8/3nPux/3O+f8h6ukUil3sVg0+M+4cFxk42/jH2wAqqqKSCSiPQdwcHHAnDHH9s/tN1h8V28ETdP+eU8fT9Nt62ancYdIPvJNtsu87bmjrJlrTDVM4RROJs1JrHPrD4Bar7A6cpc54iKOaTdJXCUI2UMVrQZ0Js7YPN18ECKkYNQcJe/OE/4dZsw7VqNXQMvHy3QZXQypQ6ycrtwDjf8aJ+PNEDSCzLpn7+m2pD8ZKHlKarYhy6XjEoCYGcN95qansQeA3fNdki+SaJZGTMQIOoL3W/Z89rxv+tokubNajlvk/vm+LFpF2XnUKZHI0I+QrI7Dw0OZTqdzUkpsM7mZTyfy5OPGyw1tK7AFSvmB/Ks8w8YwbUYbe6/3QEKv0vugfxWPnMLJun+d/kI/WLdizpNjMbAIKrhMF4OuwadBALqqs+RfInwUvuNi+fBd+wjogfogAFVRmffO02q01mZZ0HHdgXIzdz0QQLPezIQygX6llxNKKgOFARYCC49CqhoHIUTlss/Vx2phlYwjw8j1CAlfAiwQiJpiy7o1VHnsG5FISkoJu7Q/2YmmaV+i0ei7v38L2CBguSi5AAAAAElFTkSuQmCC);
background-repeat: no-repeat;
background-position: 0 .15em;
padding-left: 1.5em;
}
code {
font-family: Monaco, "Courier New", "DejaVu Sans Mono", "Bitstream Vera Sans Mono", monospace;
-webkit-border-radius: 1px;
-moz-border-radius: 1px;
border-radius: 1px;
-moz-background-clip: padding;
-webkit-background-clip: padding-box;
background-clip: padding-box;
padding: 0px 3px;
display: inline-block;
color: #52595d;
border: 1px solid #ccc;
background-color: #f9f9f9;
}
"#;
+5 -8
View File
@@ -106,14 +106,11 @@ fn find_in_inlines(inlines: &[InlineNode], line: u32, col: u32) -> Option<&Inlin
InlineNode::Superscript(s) => find_in_inlines(&s.children, line, col),
InlineNode::Subscript(s) => find_in_inlines(&s.children, line, col),
InlineNode::Color(c) => find_in_inlines(&c.children, line, col),
InlineNode::WikiLink(w) => w
.description
.as_ref()
.and_then(|d| find_in_inlines(d, line, col)),
InlineNode::ExternalLink(e) => e
.description
.as_ref()
.and_then(|d| find_in_inlines(d, line, col)),
// Links are an atomic unit for navigation: a cursor anywhere in
// `[[target|description]]` (or `[desc](url)`) should resolve the
// link itself. Don't descend into the description — returning its
// inner Text node leaves goto-definition/references/hover with
// nothing to act on ("No locations found" when on the title).
_ => None,
};
return Some(deeper.unwrap_or(n));
+4 -2
View File
@@ -307,7 +307,7 @@ fn nuwiki_toc_generates_nested_contents() {
let doc = parse(src);
let uri = Url::from_file_path("/wiki/Page.wiki").unwrap();
let edit =
ops::toc_edit(src, &doc, &uri, true, "Contents", 1, 0, 0).expect("toc edit produced");
ops::toc_edit(src, &doc, &uri, true, "Contents", 1, 0, 0, None).expect("toc edit produced");
assert!(edit.changes.is_some() || edit.document_changes.is_some());
let toc = ops::build_toc_text(
@@ -348,6 +348,7 @@ fn nuwiki_generate_links_lists_all_pages_excluding_current() {
"Generated Links",
1,
0,
None,
None
)
.is_some());
@@ -430,7 +431,8 @@ fn nuwiki_generate_tag_links_builds_section() {
true,
"Generated Tags",
1,
0
0,
None
)
.is_some());
}
@@ -32,6 +32,10 @@ fn commands_list_advertises_link_helpers() {
"nuwiki.link.pasteWikilink",
"nuwiki.link.pasteUrl",
"nuwiki.link.catUrl",
// Both GenerateLinks forms must be advertised so coc/vim-lsp accept
// them (the scoped form was previously sent but unregistered).
"nuwiki.links.generate",
"nuwiki.links.generateForPath",
] {
assert!(names.contains(&name), "missing: {name}");
}
+47 -2
View File
@@ -255,6 +255,7 @@ fn tag_links_edit_inserts_when_section_missing() {
"Generated Tags",
1,
0,
None,
)
.expect("got an edit");
let te = &edit.changes.unwrap()[&uri][0];
@@ -264,6 +265,37 @@ fn tag_links_edit_inserts_when_section_missing() {
assert!(te.new_text.contains("[[Alpha]]"));
}
#[test]
fn tag_links_edit_inserts_at_cursor_line() {
// A fresh tags section goes at the cursor line, not the EOF.
let src = "= Notes =\nbody\nmore\n";
let ast = parse(src);
let uri = Url::parse("file:///tmp/p.wiki").unwrap();
let mut snap = BTreeMap::new();
snap.insert("release".to_string(), vec!["Alpha".into()]);
let edit = ops::tag_links_edit(
src,
&ast,
&uri,
Some("release"),
&snap,
true,
"Generated Tags",
1,
0,
Some(1),
)
.expect("edit");
let te = &edit.changes.unwrap()[&uri][0];
assert_eq!(te.range.start.line, 1);
assert_eq!(te.range.start.character, 0);
assert!(
te.new_text.starts_with("\n= Tag: release ="),
"got: {:?}",
te.new_text
);
}
#[test]
fn tag_links_edit_replaces_existing_section() {
let src = "= Tag: release =\n- [[Stale]]\n\n= Other =\n";
@@ -281,6 +313,7 @@ fn tag_links_edit_replaces_existing_section() {
"Generated Tags",
1,
0,
None,
)
.expect("edit");
let te = &edit.changes.unwrap()[&uri][0];
@@ -296,7 +329,18 @@ fn tag_links_edit_full_index_replaces_existing_tags_section() {
let uri = Url::parse("file:///tmp/p.wiki").unwrap();
let mut snap = BTreeMap::new();
snap.insert("alpha".to_string(), vec!["P".into()]);
let edit = ops::tag_links_edit(src, &ast, &uri, None, &snap, true, "Generated Tags", 1, 0)
let edit = ops::tag_links_edit(
src,
&ast,
&uri,
None,
&snap,
true,
"Generated Tags",
1,
0,
None,
)
.expect("edit");
let te = &edit.changes.unwrap()[&uri][0];
assert!(te.new_text.contains("== alpha =="));
@@ -352,7 +396,8 @@ fn tag_links_edit_returns_none_for_unknown_tag() {
true,
"Generated Tags",
1,
0
0,
None
)
.is_none());
}
+58 -9
View File
@@ -385,19 +385,27 @@ fn render_page_html_numbers_headers_when_enabled() {
)
.unwrap();
assert!(
html.contains("<h1 id=\"Intro\">1. Intro</h1>"),
html.contains(
"<div id=\"Intro\"><h1 id=\"Intro\" class=\"header\"><a href=\"#Intro\">1. Intro</a></h1></div>"
),
"got: {html}"
);
assert!(
html.contains("<h2 id=\"Background\">1.1. Background</h2>"),
html.contains(
"<div id=\"Intro-Background\"><h2 id=\"Background\" class=\"header\"><a href=\"#Intro-Background\">1.1. Background</a></h2></div>"
),
"got: {html}"
);
assert!(
html.contains("<h2 id=\"Methods\">1.2. Methods</h2>"),
html.contains(
"<div id=\"Intro-Methods\"><h2 id=\"Methods\" class=\"header\"><a href=\"#Intro-Methods\">1.2. Methods</a></h2></div>"
),
"got: {html}"
);
assert!(
html.contains("<h1 id=\"Results\">2. Results</h1>"),
html.contains(
"<div id=\"Results\"><h1 id=\"Results\" class=\"header\"><a href=\"#Results\">2. Results</a></h1></div>"
),
"got: {html}"
);
}
@@ -419,14 +427,45 @@ fn render_page_html_numbering_skips_headers_above_start_level() {
)
.unwrap();
assert!(
html.contains("<h1 id=\"Title\">Title</h1>"),
html.contains(
"<div id=\"Title\"><h1 id=\"Title\" class=\"header\"><a href=\"#Title\">Title</a></h1></div>"
),
"h1 unnumbered, got: {html}"
);
assert!(
html.contains("<h2 id=\"Section\">1 Section</h2>"),
html.contains(
"<div id=\"Title-Section\"><h2 id=\"Section\" class=\"header\"><a href=\"#Title-Section\">1 Section</a></h2></div>"
),
"got: {html}"
);
assert!(
html.contains(
"<div id=\"Title-Section-Sub\"><h3 id=\"Sub\" class=\"header\"><a href=\"#Title-Section-Sub\">1.1 Sub</a></h3></div>"
),
"got: {html}"
);
}
#[test]
fn render_page_html_same_page_anchor_uses_current_filename() {
// vimwiki parity (#4): `[[#Section]]` resolves to `<page>.html#Section`,
// not a bare `#Section`, where <page> is the file being rendered.
let ast = parse("= Contents =\n\n[[#Contents]]\n");
let c = cfg("/tmp/x");
let html = export::render_page_html(
&ast,
None,
"index",
DiaryDate::from_ymd(2026, 5, 11).unwrap(),
&c.html,
None,
HashMap::new(),
)
.unwrap();
assert!(
html.contains("<a href=\"index.html#Contents\">"),
"got: {html}"
);
assert!(html.contains("<h3 id=\"Sub\">1.1 Sub</h3>"), "got: {html}");
}
#[test]
@@ -443,8 +482,18 @@ fn render_page_html_no_header_numbering_by_default() {
HashMap::new(),
)
.unwrap();
assert!(html.contains("<h1 id=\"Intro\">Intro</h1>"), "got: {html}");
assert!(html.contains("<h2 id=\"Sub\">Sub</h2>"), "got: {html}");
assert!(
html.contains(
"<div id=\"Intro\"><h1 id=\"Intro\" class=\"header\"><a href=\"#Intro\">Intro</a></h1></div>"
),
"got: {html}"
);
assert!(
html.contains(
"<div id=\"Intro-Sub\"><h2 id=\"Sub\" class=\"header\"><a href=\"#Intro-Sub\">Sub</a></h2></div>"
),
"got: {html}"
);
}
#[test]
@@ -152,6 +152,28 @@ fn v1_0_single_wiki_desugars_to_one_entry() {
assert_eq!(cfg.wikis[0].syntax, "vimwiki");
}
#[test]
fn single_wiki_shorthand_honours_top_level_per_wiki_keys() {
// Regression: a single-wiki user sets per-wiki keys at the top level
// (alongside wiki_root); they must reach the synthesized wiki, not just
// file_extension/syntax. Previously toc_header_level etc. were dropped.
let cfg = config_from_json(json!({
"wiki_root": "/tmp/vimwiki",
"toc_header": "Table of Contents",
"toc_header_level": 2,
"links_header_level": 3,
"auto_export": true,
"html_path": "/tmp/out",
}));
assert_eq!(cfg.wikis.len(), 1);
let w = &cfg.wikis[0];
assert_eq!(w.toc_header, "Table of Contents");
assert_eq!(w.toc_header_level, 2);
assert_eq!(w.links_header_level, 3);
assert!(w.html.auto_export);
assert_eq!(w.html.html_path, PathBuf::from("/tmp/out"));
}
#[test]
fn explicit_wikis_list_overrides_legacy_shape() {
let cfg = config_from_json(json!({
@@ -652,3 +674,20 @@ fn legacy_single_wiki_shape_picks_up_defaults() {
assert_eq!(cfg.wikis[0].index, "index");
assert_eq!(cfg.wikis[0].diary_frequency, "daily");
}
#[test]
fn vimwiki_compat_payload_sets_toc_level_per_wiki() {
// The exact shape the Vim client emits when translating g:vimwiki_list +
// g:vimwiki_toc_header_level=2 (drop-in vimwiki compat).
let cfg = config_from_json(json!({
"wiki_root": "~/vimwiki",
"wikis": [
{ "root": "/tmp/personal", "toc_header_level": 2, "html_header_numbering": 2 },
{ "root": "/tmp/ifood", "toc_header_level": 2 },
],
}));
assert_eq!(cfg.wikis.len(), 2);
assert_eq!(cfg.wikis[0].toc_header_level, 2);
assert_eq!(cfg.wikis[0].html.html_header_numbering, 2);
assert_eq!(cfg.wikis[1].toc_header_level, 2);
}
+73 -3
View File
@@ -484,7 +484,8 @@ fn toc_edit_inserts_at_top_when_no_existing_toc() {
let src = "= One =\n== Two ==\n";
let ast = parse(src);
let uri = Url::parse("file:///tmp/page.wiki").unwrap();
let edit = ops::toc_edit(src, &ast, &uri, true, "Contents", 1, 0, 0).expect("got an edit");
let edit =
ops::toc_edit(src, &ast, &uri, true, "Contents", 1, 0, 0, None).expect("got an edit");
let changes = edit.changes.expect("changes map");
let edits = &changes[&uri];
assert_eq!(edits.len(), 1);
@@ -497,12 +498,47 @@ fn toc_edit_inserts_at_top_when_no_existing_toc() {
assert!(te.new_text.contains("[[#One|One]]"));
}
#[test]
fn toc_edit_inserts_at_cursor_line() {
// A fresh TOC goes at the cursor line (0-based) the client sends.
let src = "= One =\nbody text\n== Two ==\nmore\n";
let ast = parse(src);
let uri = Url::parse("file:///tmp/page.wiki").unwrap();
// Cursor on line 1 ("body text").
let edit = ops::toc_edit(src, &ast, &uri, true, "Contents", 1, 0, 0, Some(1)).expect("edit");
let te = &edit.changes.unwrap()[&uri][0];
// Zero-width insert at line 1, column 0.
assert_eq!(te.range.start, te.range.end);
assert_eq!(te.range.start.line, 1);
assert_eq!(te.range.start.character, 0);
// A leading blank separates it from the preceding text.
assert!(
te.new_text.starts_with("\n= Contents ="),
"got: {:?}",
te.new_text
);
}
#[test]
fn toc_edit_cursor_line_clamped_and_existing_replaced_in_place() {
// An existing TOC is replaced in place regardless of the cursor line.
let src = "= Contents =\n- [[#stale|Stale]]\n\n= Real =\n";
let ast = parse(src);
let uri = Url::parse("file:///tmp/page.wiki").unwrap();
let edit = ops::toc_edit(src, &ast, &uri, true, "Contents", 1, 0, 0, Some(99)).expect("edit");
let te = &edit.changes.unwrap()[&uri][0];
// Replacement starts at line 0 (the existing section), not the cursor.
assert_eq!(te.range.start.line, 0);
assert!(te.new_text.contains("[[#Real|Real]]"));
}
#[test]
fn toc_edit_replaces_existing_toc() {
let src = "= Contents =\n- [[#stale|Stale]]\n\n= Real =\n";
let ast = parse(src);
let uri = Url::parse("file:///tmp/page.wiki").unwrap();
let edit = ops::toc_edit(src, &ast, &uri, true, "Contents", 1, 0, 0).expect("got an edit");
let edit =
ops::toc_edit(src, &ast, &uri, true, "Contents", 1, 0, 0, None).expect("got an edit");
let changes = edit.changes.expect("changes map");
let edits = &changes[&uri];
let te = &edits[0];
@@ -517,7 +553,7 @@ fn toc_edit_returns_none_for_empty_doc() {
let src = "";
let ast = parse(src);
let uri = Url::parse("file:///tmp/empty.wiki").unwrap();
assert!(ops::toc_edit(src, &ast, &uri, true, "Contents", 1, 0, 0).is_none());
assert!(ops::toc_edit(src, &ast, &uri, true, "Contents", 1, 0, 0, None).is_none());
}
#[test]
@@ -561,6 +597,7 @@ fn links_edit_inserts_when_section_absent() {
1,
0,
None,
None,
)
.expect("edit");
let te = &edit.changes.unwrap()[&uri][0];
@@ -569,6 +606,37 @@ fn links_edit_inserts_when_section_absent() {
assert!(!te.new_text.contains("[[Home]]"));
}
#[test]
fn links_edit_inserts_at_cursor_line() {
// A fresh Generated Links section goes at the cursor line, not the EOF.
let src = "= Home =\nbody\nmore\n";
let ast = parse(src);
let uri = Url::parse("file:///tmp/page.wiki").unwrap();
let pages = vec!["A".into()];
let edit = ops::links_edit(
src,
&ast,
&uri,
"Home",
&pages,
true,
"Generated Links",
1,
0,
None,
Some(1),
)
.expect("edit");
let te = &edit.changes.unwrap()[&uri][0];
assert_eq!(te.range.start.line, 1);
assert_eq!(te.range.start.character, 0);
assert!(
te.new_text.starts_with("\n= Generated Links ="),
"got: {:?}",
te.new_text
);
}
#[test]
fn links_edit_replaces_when_section_present() {
let src = "= Generated Links =\n- [[Stale]]\n\n= Real =\n";
@@ -586,6 +654,7 @@ fn links_edit_replaces_when_section_present() {
1,
0,
None,
None,
)
.expect("edit");
let te = &edit.changes.unwrap()[&uri][0];
@@ -609,6 +678,7 @@ fn links_edit_returns_none_for_empty_page_list() {
"Generated Links",
1,
0,
None,
None
)
.is_none());
+12
View File
@@ -201,6 +201,18 @@ fn find_inline_at_returns_wikilink_under_cursor() {
assert!(matches!(node, InlineNode::WikiLink(_)));
}
#[test]
fn find_inline_at_returns_wikilink_when_cursor_on_description() {
// Regression: pressing <CR> on the *title* part of a piped link used to
// descend into the description's Text node, so goto-definition saw a Text
// (not a WikiLink) and reported "No locations found".
let src = "see [[Other|My Title]] now\n";
let doc = parse(src);
// Byte col 16 is inside "Title" (the description), past the '|' at 11.
let node = find_inline_at(&doc, 0, 16).expect("wikilink at cursor");
assert!(matches!(node, InlineNode::WikiLink(_)));
}
#[test]
fn find_inline_at_returns_none_for_plain_text() {
let src = "just text\n";
+6
View File
@@ -64,6 +64,12 @@ vim.g.nuwiki_diary_rel_path = 'diary'
require('nuwiki').setup({
wiki_root = '$WIKI_DIR',
-- Exercise the per-wiki display settings via the global shorthand: these
-- fold into the single wiki, so :NuwikiTOC writes `== Contents ==` (level 2)
-- and HTML export numbers headings.
toc_header_level = 2,
html_header_numbering = 2,
html_header_numbering_sym = ' -',
})
-- Surface log lines (the LSP logs via window/logMessage). Without this
+33 -31
View File
@@ -24,6 +24,7 @@
# NUWIKI_DEV_NO_SEED=1 ./development/start-vim-coc.sh # use WIKI_DIR as-is (no scratch seeding)
# NUWIKI_DEV_SKIP_BUILD=1 ./development/start-vim-coc.sh # reuse the cached binary
# NUWIKI_DEV_COC_JUMP=edit ./development/start-vim-coc.sh # change coc.preferences.jumpCommand
# NUWIKI_DEV_VIMWIKI=1 ./development/start-vim-coc.sh # drive nuwiki from a g:vimwiki_list config (drop-in)
#
# The release binary is rebuilt on every launch so source changes always reach
# the running LSP. `cargo build --release` is incremental, so this is a fast
@@ -72,42 +73,43 @@ ensure_coc() {
write_coc_settings() {
mkdir -p "$DEV_DIR"
# Mirror what the vim-lsp client sends (autoload/nuwiki/lsp.vim s:settings()):
# both `initializationOptions` (sent on initialize) and `settings.nuwiki`
# (served via workspace/configuration). Without these the server falls back
# to its own default wiki_root (~/vimwiki) instead of $WIKI_DIR, so it can't
# resolve links (e.g. Notes shows as broken) or create-on-follow correctly.
# No `languageserver.nuwiki` block here on purpose: this dogfoods nuwiki's
# *programmatic* coc registration (autoload/nuwiki/lsp.vim → coc#config). The
# plugin registers the server itself from g:nuwiki_* (set in the vimrc),
# exactly the "no hand-written coc-settings.json" flow real users get. Only
# the jump-command preference (the <CR>-opens-a-tab reproducer) lives here.
cat > "$COC_SETTINGS" <<EOF
{
"coc.preferences.jumpCommand": "$COC_JUMP",
"languageserver": {
"nuwiki": {
"command": "$REPO_ROOT/bin/nuwiki-ls",
"filetypes": ["vimwiki"],
"initializationOptions": {
"wiki_root": "$WIKI_DIR",
"file_extension": ".wiki",
"syntax": "vimwiki",
"log_level": "info",
"diagnostic": { "link_severity": "warn" }
},
"settings": {
"nuwiki": {
"wiki_root": "$WIKI_DIR",
"file_extension": ".wiki",
"syntax": "vimwiki",
"log_level": "info",
"diagnostic": { "link_severity": "warn" }
}
}
}
}
"coc.preferences.jumpCommand": "$COC_JUMP"
}
EOF
}
write_vimrc() {
mkdir -p "$DEV_DIR"
# Wiki config block. Default: native g:nuwiki_* (single wiki). With
# NUWIKI_DEV_VIMWIKI=1: an upstream g:vimwiki_list config instead, to dogfood
# the g:vimwiki_* drop-in translation (the path real vimwiki migrants use).
local WIKI_CFG
if [[ "${NUWIKI_DEV_VIMWIKI:-0}" == "1" ]]; then
log "NUWIKI_DEV_VIMWIKI=1 — driving nuwiki from an upstream g:vimwiki_list config"
WIKI_CFG="\" Upstream vimwiki config (drop-in). nuwiki translates these.
let g:vimwiki_toc_header_level = 2
let g:vimwiki_html_header_numbering = 2
let g:vimwiki_html_header_numbering_sym = ' -'
let g:vimwiki_list = [{'path': '${WIKI_DIR}', 'path_html': '${DEV_DIR}/html', 'ext': '.wiki'}]"
else
WIKI_CFG="let g:nuwiki_wiki_root = '${WIKI_DIR}'
let g:nuwiki_file_extension = '.wiki'
let g:nuwiki_diary_rel_path = 'diary'
\" Display settings exercise the global shorthand: :NuwikiTOC writes
\" \`== Contents ==\` (level 2) and HTML export numbers headings.
let g:nuwiki_toc_header_level = 2
let g:nuwiki_html_header_numbering = 2
let g:nuwiki_html_header_numbering_sym = ' -'"
fi
cat > "$VIMRC" <<EOF
" start-vim-coc.sh: generated minimal config for nuwiki development (coc.nvim).
" Regenerated on every launch; edit start-vim-coc.sh, not this file.
@@ -131,11 +133,11 @@ let g:coc_config_home = '${DEV_DIR}'
let g:coc_data_home = '${COC_DATA}'
let g:nuwiki_binary_path = '${REPO_ROOT}/bin/nuwiki-ls'
let g:nuwiki_wiki_root = '${WIKI_DIR}'
let g:nuwiki_file_extension = '.wiki'
let g:nuwiki_syntax = 'vimwiki'
let g:nuwiki_diary_rel_path = 'diary'
let g:nuwiki_log_level = 'info'
" nuwiki auto-registers itself with coc from these globals (no coc-settings.json
" languageserver block).
${WIKI_CFG}
filetype plugin indent on
syntax enable
+6
View File
@@ -87,6 +87,12 @@ let g:nuwiki_wiki_root = '${WIKI_DIR}'
let g:nuwiki_file_extension = '.wiki'
let g:nuwiki_syntax = 'vimwiki'
let g:nuwiki_log_level = 'info'
" Exercise the per-wiki display settings via the global shorthand: these fold
" into the single wiki, so :NuwikiTOC writes `== Contents ==` (level 2) and
" HTML export numbers headings.
let g:nuwiki_toc_header_level = 2
let g:nuwiki_html_header_numbering = 2
let g:nuwiki_html_header_numbering_sym = ' -'
filetype plugin indent on
syntax enable
+18
View File
@@ -89,6 +89,24 @@ call s:record(winnr('$') == s:wins_before - 1, 'action.window_count_decremented'
call s:record(&filetype ==# 'vimwiki', 'action.sets_filetype',
\ 'filetype=' . &filetype)
" ===== calendar follows the current wiki (multi-wiki) =====
" Two wikis, each with its own diary entry. track_wiki() (driven by the
" BufEnter autocmd in real use) switches which wiki the calendar reads.
let s:wA = tempname() | call mkdir(s:wA . '/diary', 'p')
let s:wB = tempname() | call mkdir(s:wB . '/diary', 'p')
call writefile(['a'], s:wA . '/diary/2026-06-02.wiki')
call writefile(['b'], s:wB . '/diary/2026-06-10.wiki')
let g:nuwiki_wikis = [{'root': s:wA}, {'root': s:wB}]
call nuwiki#diary#track_wiki(s:wA . '/index.wiki')
call s:record(nuwiki#diary#calendar_sign(2, 6, 2026) ==# '*', 'track.wikiA_own_entry', '')
call s:record(nuwiki#diary#calendar_sign(10, 6, 2026) ==# '', 'track.wikiA_not_wikiB_entry', '')
call nuwiki#diary#track_wiki(s:wB . '/index.wiki')
call s:record(nuwiki#diary#calendar_sign(10, 6, 2026) ==# '*', 'track.wikiB_own_entry', '')
call s:record(nuwiki#diary#calendar_sign(2, 6, 2026) ==# '', 'track.wikiB_not_wikiA_entry', '')
unlet g:nuwiki_wikis
" ===== Wrap up =====
call add(s:results, '')
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env bash
#
# development/tests/test-coc-register-vim.sh — programmatic coc.nvim
# registration for the Vim client.
#
# nuwiki#lsp#start() registers the language server with coc via coc#config so
# users don't hand-maintain coc-settings.json. This harness stubs coc#config
# (a real autoload/coc.vim on the runtimepath, since Vim's exists('*coc#config')
# goes through the autoload mechanism) to record what nuwiki injects, then runs
# test-coc-register-vim.vim and asserts the payload.
#
# Exit code: 0 when every check passes, 1 on any FAIL or missing output.
# Skips (exit 0) when vim is not installed.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
log() { printf '\033[1;34m[coc-register]\033[0m %s\n' "$*"; }
if ! command -v vim >/dev/null 2>&1; then
log 'vim not installed — skipping'
exit 0
fi
TMP="$(mktemp -d -t nuwiki-coc-test-XXXXXX)"
trap 'rm -rf "$TMP"' EXIT
# Stub coc#config as a real autoload function so exists('*coc#config') resolves
# it. It records the last call into g:_recorded for the test to assert.
mkdir -p "$TMP/stub/autoload"
cat > "$TMP/stub/autoload/coc.vim" <<'EOF'
function! coc#config(section, value) abort
let g:_recorded = {'section': a:section, 'value': deepcopy(a:value)}
endfunction
EOF
OUT="$TMP/coc.out"
VIMRC="$TMP/vimrc"
cat > "$VIMRC" <<EOF
set nocompatible
" Stub dir first so its autoload/coc.vim is the one that resolves.
let &runtimepath = '$TMP/stub' . ',' . '$REPO_ROOT' . ',' . &runtimepath
" coc.nvim defines :CocConfig at startup; nuwiki uses that as the reliable
" 'coc is present' signal. Stub it so the coc branch is taken.
command! -nargs=0 CocConfig echo ''
EOF
log 'running Vim coc-registration harness…'
NUWIKI_COC_OUT="$OUT" \
timeout 30 vim -e -s -u "$VIMRC" \
-c "source $REPO_ROOT/development/tests/test-coc-register-vim.vim" \
</dev/null >"$TMP/vim.log" 2>&1 || true
if [[ ! -f "$OUT" ]]; then
echo 'coc-registration harness produced no output' >&2
echo '--- vim log ---' >&2
cat "$TMP/vim.log" >&2 || true
exit 1
fi
cat "$OUT"
PASSED="$(grep -c '^PASS ' "$OUT" || true)"
FAILED="$(grep -c '^FAIL ' "$OUT" || true)"
echo "SUMMARY: ${PASSED} passed, ${FAILED} failed"
if [[ "$FAILED" -ne 0 ]]; then
exit 1
fi
log 'coc registration OK ✓'
exit 0
@@ -0,0 +1,42 @@
" development/tests/test-coc-register-vim.vim — programmatic coc.nvim
" registration. Relies on a stub autoload/coc.vim (created by the wrapper) that
" records coc#config calls into g:_recorded. Drives nuwiki#lsp#start()'s coc
" branch and asserts the exact languageserver config nuwiki injects (command,
" filetypes, and the s:settings() payload with the global shorthand folded in).
" Writes PASS/FAIL to $NUWIKI_COC_OUT.
let s:out = []
function! s:check(desc, cond) abort
call add(s:out, (a:cond ? 'PASS ' : 'FAIL ') . a:desc)
endfunction
" Pretend coc's node service is already up so registration runs synchronously.
let g:coc_service_initialized = 1
let g:_recorded = {}
" Make sure the vim-lsp branch is NOT taken.
if exists('g:lsp_loaded') | unlet g:lsp_loaded | endif
" A readable, executable 'binary' so nuwiki#lsp#start() gets past its check.
let g:nuwiki_binary_path = executable('true') ? exepath('true') : '/usr/bin/true'
" Config: single-wiki shorthand + a global display setting that must fold into
" the wiki the server sees (the global shorthand).
let g:nuwiki_wiki_root = '/tmp/realwiki'
let g:nuwiki_toc_header_level = 2
call nuwiki#lsp#start()
let s:r = get(g:, '_recorded', {})
call s:check('coc#config was called', !empty(s:r))
call s:check('section is languageserver.nuwiki', get(s:r, 'section', '') ==# 'languageserver.nuwiki')
let s:v = get(s:r, 'value', {})
call s:check('command is the binary', get(s:v, 'command', '') ==# g:nuwiki_binary_path)
call s:check('filetypes = [vimwiki]', get(s:v, 'filetypes', []) == ['vimwiki'])
let s:io = get(s:v, 'initializationOptions', {})
call s:check('initOpts has wiki_root', get(s:io, 'wiki_root', '') ==# '/tmp/realwiki')
call s:check('global toc_header_level folded into payload', get(s:io, 'toc_header_level', 0) == 2)
call s:check('settings.nuwiki mirrors initOpts', get(get(s:v, 'settings', {}), 'nuwiki', {}) ==# s:io)
call writefile(s:out, $NUWIKI_COC_OUT)
qa!
@@ -0,0 +1,87 @@
-- development/tests/test-global-shorthand.lua — Neovim client global
-- shorthand: a per-wiki setting given once at the top level (setup() or
-- g:nuwiki_<key>) is folded into every wiki as a default, and a per-wiki
-- value overrides it. Mirror of the Vim test-vimwiki-compat checks.
--
-- Writes PASS/FAIL lines to $NUWIKI_GS_OUT; the wrapper fails on any FAIL.
local config = require('nuwiki.config')
local lsp = require('nuwiki.lsp')
local out = {}
local function check(desc, cond)
table.insert(out, (cond and 'PASS ' or 'FAIL ') .. desc)
end
-- ----- setup() top-level scalar folds into every wiki; per-wiki wins -----
config.apply({
toc_header_level = 2,
html_header_numbering = 2,
wikis = {
{ root = '/tmp/one', name = 'One' },
{ root = '/tmp/two', name = 'Two', toc_header_level = 3 },
},
})
local io1 = lsp.init_options()
check('w0 inherits global level', io1.wikis[1].toc_header_level == 2)
check('w0 inherits numbering', io1.wikis[1].html_header_numbering == 2)
check('w1 per-wiki override wins', io1.wikis[2].toc_header_level == 3)
check('w1 still inherits numbering', io1.wikis[2].html_header_numbering == 2)
-- The user's setup table must not be mutated by the fold.
check('no mutation of user wikis', config.user.wikis[1].toc_header_level == nil)
-- ----- built-in defaults are NOT folded (only explicit user values) -----
config.apply({
wikis = { { root = '/tmp/x', name = 'X' } },
})
local io2 = lsp.init_options()
check('default toc_header not folded', io2.wikis[1].toc_header == nil)
check('default level not folded', io2.wikis[1].toc_header_level == nil)
-- ----- g:nuwiki_<key> acts as a global too -----
config.apply({
wikis = { { root = '/tmp/y', name = 'Y' } },
})
vim.g.nuwiki_toc_header_level = 5
local io3 = lsp.init_options()
check('g:nuwiki_ global folds', io3.wikis[1].toc_header_level == 5)
vim.g.nuwiki_toc_header_level = nil
-- ----- upstream vim.g.vimwiki_list drop-in (the lazy.nvim vimwiki-swap case) --
config.apply({}) -- setup() with no native config
vim.g.vimwiki_list = {
{ path = '~/.vimwiki/personal', path_html = '~/public_html/personal', auto_export = 1 },
{ path = '~/.vimwiki/work' },
}
vim.g.vimwiki_toc_header_level = 2
local io4 = lsp.init_options()
check('vimwiki_list -> 2 wikis', io4.wikis ~= nil and #io4.wikis == 2)
check('path -> root', io4.wikis[1].root == '~/.vimwiki/personal')
check('path_html -> html_path', io4.wikis[1].html_path == '~/public_html/personal')
check('vimwiki global folds', io4.wikis[1].toc_header_level == 2)
check('w2 root', io4.wikis[2].root == '~/.vimwiki/work')
-- Buffer-side commands (<Leader>ww) resolve the same wikis.
check('wiki_list reads vimwiki_list', config.wiki_list()[1].root == '~/.vimwiki/personal')
-- Native g:nuwiki_wikis wins over g:vimwiki_list.
vim.g.nuwiki_wikis = { { root = '~/native', name = 'native' } }
local io5 = lsp.init_options()
check('native g:nuwiki_wikis wins', #io5.wikis == 1 and io5.wikis[1].root == '~/native')
vim.g.nuwiki_wikis = nil
vim.g.vimwiki_list = nil
vim.g.vimwiki_toc_header_level = nil
-- ----- setup({ wikis }) bridges the Lua config to VimL (g:nuwiki_wikis) -----
-- So buffer-side VimL helpers + the vimwiki-sync compat shim see the same
-- wikis as the LSP, even though setup() config lives only in Lua.
require('nuwiki').setup({ wikis = { { root = '/tmp/a', name = 'A' }, { root = '/tmp/b' } } })
check('setup() bridges to g:nuwiki_wikis',
vim.g.nuwiki_wikis ~= nil and #vim.g.nuwiki_wikis == 2)
check('bridged wiki root visible to VimL',
vim.g.nuwiki_wikis and vim.g.nuwiki_wikis[1].root == '/tmp/a')
vim.g.nuwiki_wikis = nil
local f = assert(io.open(os.getenv('NUWIKI_GS_OUT'), 'w'))
f:write(table.concat(out, '\n') .. '\n')
f:close()
vim.cmd('qa!')
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env bash
#
# development/tests/test-global-shorthand.sh — Neovim client global-shorthand
# folding (vimwiki-style per-wiki defaults). Runs test-global-shorthand.lua
# under headless Neovim; that script asserts the init_options payload folds a
# top-level / g:nuwiki_* setting into every wiki, lets a per-wiki value
# override it, and does NOT fold built-in defaults.
#
# Exit code: 0 when every check passes, 1 on any FAIL or missing output.
# Skips (exit 0) when nvim is not installed.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
log() { printf '\033[1;34m[global-shorthand]\033[0m %s\n' "$*"; }
if ! command -v nvim >/dev/null 2>&1; then
log 'nvim not installed — skipping'
exit 0
fi
TMP="$(mktemp -d -t nuwiki-gs-test-XXXXXX)"
trap 'rm -rf "$TMP"' EXIT
OUT="$TMP/gs.out"
INIT="$TMP/init.lua"
cat > "$INIT" <<EOF
vim.opt.runtimepath:prepend('$REPO_ROOT')
EOF
log 'running Neovim global-shorthand harness…'
NUWIKI_GS_OUT="$OUT" \
timeout 30 nvim --clean -u "$INIT" --headless \
-c "luafile $REPO_ROOT/development/tests/test-global-shorthand.lua" \
>"$TMP/nvim.log" 2>&1 || true
if [[ ! -f "$OUT" ]]; then
echo 'global-shorthand harness produced no output' >&2
echo '--- nvim log ---' >&2
cat "$TMP/nvim.log" >&2 || true
exit 1
fi
cat "$OUT"
PASSED="$(grep -c '^PASS ' "$OUT" || true)"
FAILED="$(grep -c '^FAIL ' "$OUT" || true)"
echo "SUMMARY: ${PASSED} passed, ${FAILED} failed"
if [[ "$FAILED" -ne 0 ]]; then
exit 1
fi
log 'global shorthand OK ✓'
exit 0
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/env bash
#
# development/tests/test-vimwiki-compat-vim.sh — upstream-vimwiki config
# drop-in compatibility for the Vim client.
#
# Runs test-vimwiki-compat-vim.vim under headless Vim; that script sets an
# upstream `g:vimwiki_list` + `g:vimwiki_*` config and asserts
# nuwiki#lsp#settings() translates it into the server's schema (per-wiki key
# remap + global scalar settings folded into each wiki). Also checks that
# nuwiki-native config takes precedence.
#
# Exit code: 0 when every check passes, 1 on any FAIL or missing output.
# Skips (exit 0) when vim is not installed.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
log() { printf '\033[1;34m[vimwiki-compat]\033[0m %s\n' "$*"; }
if ! command -v vim >/dev/null 2>&1; then
log 'vim not installed — skipping'
exit 0
fi
TMP="$(mktemp -d -t nuwiki-vwc-test-XXXXXX)"
trap 'rm -rf "$TMP"' EXIT
OUT="$TMP/vwc.out"
VIMRC="$TMP/vimrc"
cat > "$VIMRC" <<EOF
set nocompatible
let &runtimepath = '$REPO_ROOT' . ',' . &runtimepath
EOF
log 'running Vim vimwiki-compat harness…'
NUWIKI_VWC_OUT="$OUT" \
timeout 30 vim -e -s -u "$VIMRC" \
-c "source $REPO_ROOT/development/tests/test-vimwiki-compat-vim.vim" \
</dev/null >"$TMP/vim.log" 2>&1 || true
if [[ ! -f "$OUT" ]]; then
echo 'vimwiki-compat harness produced no output' >&2
echo '--- vim log ---' >&2
cat "$TMP/vim.log" >&2 || true
exit 1
fi
cat "$OUT"
PASSED="$(grep -c '^PASS ' "$OUT" || true)"
FAILED="$(grep -c '^FAIL ' "$OUT" || true)"
echo "SUMMARY: ${PASSED} passed, ${FAILED} failed"
if [[ "$FAILED" -ne 0 ]]; then
exit 1
fi
log 'vimwiki config compat OK ✓'
exit 0
@@ -0,0 +1,86 @@
" development/tests/test-vimwiki-compat-vim.vim — upstream-vimwiki config
" drop-in compatibility for the Vim client.
"
" Sourced by test-vimwiki-compat-vim.sh under headless Vim. Sets an upstream
" `g:vimwiki_list` + `g:vimwiki_*` config and asserts nuwiki#lsp#settings()
" translates it into the server's schema. Writes PASS/FAIL to
" $NUWIKI_VWC_OUT; the wrapper fails on any FAIL.
let s:out = []
function! s:check(desc, cond) abort
call add(s:out, (a:cond ? 'PASS ' : 'FAIL ') . a:desc)
endfunction
" ----- upstream vimwiki config (no nuwiki-native vars set) -----
let g:vimwiki_toc_header_level = 2
let g:vimwiki_html_header_numbering = 2
let g:vimwiki_html_header_numbering_sym = ' -'
let s:personal = {}
let s:personal.path = '~/.vimwiki/personal_wiki'
let s:personal.path_html = '~/public_html/personal_wiki'
let s:personal.template_default = 'default'
let s:personal.template_ext = '.tpl'
let s:personal.auto_export = 1
let s:ifood = {}
let s:ifood.path = '~/.vimwiki/ifood_wiki'
let g:vimwiki_list = [s:personal, s:ifood]
let s:cfg = nuwiki#lsp#settings()
call s:check('has wikis list', has_key(s:cfg, 'wikis'))
call s:check('two wikis', has_key(s:cfg, 'wikis') && len(s:cfg.wikis) == 2)
let s:w0 = get(s:cfg, 'wikis', [{}])[0]
let s:w1 = get(s:cfg, 'wikis', [{},{}])[1]
call s:check('w0 path->root', get(s:w0, 'root', '') ==# '~/.vimwiki/personal_wiki')
call s:check('w0 path_html->html_path', get(s:w0, 'html_path', '') ==# '~/public_html/personal_wiki')
call s:check('w0 template_default', get(s:w0, 'template_default', '') ==# 'default')
call s:check('w0 auto_export', get(s:w0, 'auto_export', 0) == 1)
call s:check('w0 toc_header_level=2', get(s:w0, 'toc_header_level', 0) == 2)
call s:check('w0 html_numbering=2', get(s:w0, 'html_header_numbering', 0) == 2)
call s:check('w0 numbering_sym', get(s:w0, 'html_header_numbering_sym', '') ==# ' -')
call s:check('w1 root', get(s:w1, 'root', '') ==# '~/.vimwiki/ifood_wiki')
call s:check('w1 globals applied', get(s:w1, 'toc_header_level', 0) == 2)
" The buffer-side commands (e.g. <Leader>ww) must resolve the SAME wikis from
" g:vimwiki_list — not just the LSP payload. Regression guard for "<Leader>ww
" opens an empty ~/vimwiki".
let s:wl = nuwiki#commands#wiki_list()
call s:check('wiki_list sees vimwiki config', len(s:wl) == 2)
call s:check('wiki_list w0 root', len(s:wl) >= 1 && s:wl[0].root ==# expand('~/.vimwiki/personal_wiki'))
call s:check('wiki_list w1 root', len(s:wl) >= 2 && s:wl[1].root ==# expand('~/.vimwiki/ifood_wiki'))
" ----- nuwiki-native config wins over vimwiki -----
let g:nuwiki_wikis = [{'root': '~/native', 'name': 'native'}]
let s:cfg2 = nuwiki#lsp#settings()
call s:check('native g:nuwiki_wikis wins',
\ len(get(s:cfg2, 'wikis', [])) == 1
\ && get(s:cfg2.wikis[0], 'root', '') ==# '~/native')
unlet g:nuwiki_wikis
" ----- nuwiki-native scalar overrides the vimwiki global -----
let g:vimwiki_list = []
let g:nuwiki_toc_header_level = 4
let s:cfg3 = nuwiki#lsp#settings()
" No wikis list now (vimwiki_list empty) → scalar folded into top-level.
call s:check('native scalar override', get(s:cfg3, 'toc_header_level', 0) == 4)
unlet g:nuwiki_toc_header_level
" ----- global shorthand on native g:nuwiki_wikis (vimwiki-style) -----
" A global default applies to every wiki; a per-wiki value overrides it.
let g:nuwiki_toc_header_level = 2
let g:nuwiki_html_header_numbering = 2
let g:nuwiki_wikis = [
\ {'root': '~/w1', 'name': 'One'},
\ {'root': '~/w2', 'name': 'Two', 'toc_header_level': 3},
\ ]
let s:cfg4 = nuwiki#lsp#settings()
let s:g0 = s:cfg4.wikis[0]
let s:g1 = s:cfg4.wikis[1]
call s:check('global folds into wiki 0', get(s:g0, 'toc_header_level', 0) == 2)
call s:check('global numbering folds', get(s:g0, 'html_header_numbering', 0) == 2)
call s:check('per-wiki override wins', get(s:g1, 'toc_header_level', 0) == 3)
call s:check('global still folds elsewhere', get(s:g1, 'html_header_numbering', 0) == 2)
" The user's original dict must not be mutated by the fold.
call s:check('no mutation of g:nuwiki_wikis', !has_key(g:nuwiki_wikis[0], 'toc_header_level'))
call writefile(s:out, $NUWIKI_VWC_OUT)
+10 -4
View File
@@ -525,11 +525,15 @@ Page generation ~
*:NuwikiTOC*
:NuwikiTOC
Generate or refresh the table of contents on the current page.
Generate or refresh the table of contents on the current page. A new TOC
is inserted at the cursor line; an existing one is refreshed in place.
*:NuwikiGenerateLinks*
:NuwikiGenerateLinks
Insert a flat list of every page in the wiki under the cursor.
:NuwikiGenerateLinks [path]
Insert a flat list of every page in the wiki. With a `path` argument, the
list is scoped to that subtree (a wiki-root-relative directory, or a glob
pattern containing `*`/`?`). A new section is inserted at the cursor line;
an existing one is refreshed in place.
*:NuwikiCheckLinks*
:[range]NuwikiCheckLinks
@@ -555,7 +559,9 @@ Tags ~
*:NuwikiGenerateTagLinks*
:NuwikiGenerateTagLinks [tag]
Insert a section linking every page that has `<tag>`. With no
argument, generates a section per tag found in the workspace.
argument, generates a section per tag found in the workspace. A new
section is inserted at the cursor line; an existing one is refreshed in
place.
*:NuwikiRebuildTags*
:NuwikiRebuildTags[!]
+10
View File
@@ -128,6 +128,16 @@ correct as-is; the deferral is a deliberate risk/reward call.
## Notes
- **Upstream `g:vimwiki_list` config (Vim & Neovim).** When you haven't
configured nuwiki natively, both clients read your upstream `g:vimwiki_list` +
`g:vimwiki_*` globals and translate them into nuwiki's schema (per-wiki
`path`/`path_html`/`template_*`/`auto_export`, plus globals `toc_header`,
`toc_header_level`, `html_header_numbering`, `html_header_numbering_sym`,
`links_space_char`, `list_margin`). `g:nuwiki_*` / `setup()` config always
wins. Globals nuwiki doesn't model (e.g. `automatic_nested_syntaxes`) are
ignored — see the divergence list above. On Neovim this means swapping the
`vimwiki` plugin for nuwiki under lazy.nvim works with your existing
`vim.g.vimwiki_list` untouched (call `require('nuwiki').setup()` with no args).
- **Third-party plugin compatibility.** A shim at `autoload/vimwiki/vars.vim`
exposes the subset of `vimwiki#vars#get_wikilocal` that plugins like
vimwiki-sync and vim-zettel call (`path`, `ext`/`extension`, `syntax`,
+11 -6
View File
@@ -513,16 +513,20 @@ end
-- ===== Generation + workspace =====
M.toc_generate = function() exec('nuwiki.toc.generate', uri_args()) end
M.toc_generate = function()
-- Send the cursor line (0-based) so a fresh TOC is inserted there.
local line = vim.api.nvim_win_get_cursor(0)[1] - 1
exec('nuwiki.toc.generate', { { uri = buf_uri(), line = line } })
end
-- `:VimwikiGenerateLinks [rel_path]` — optional path scopes the links to a
-- subtree via the server's generateForPath; no arg = every page.
M.links_generate = function(path)
-- Cursor line (0-based) so a fresh section is inserted there.
local line = vim.api.nvim_win_get_cursor(0)[1] - 1
if path and path ~= '' then
local args = uri_args()[1]
args.path = path
exec('nuwiki.links.generateForPath', { args })
exec('nuwiki.links.generateForPath', { { uri = buf_uri(), path = path, line = line } })
else
exec('nuwiki.links.generate', uri_args())
exec('nuwiki.links.generate', { { uri = buf_uri(), line = line } })
end
end
@@ -602,7 +606,8 @@ function M.tags_search(query)
end
function M.tags_generate_links(tag)
local args = { uri = buf_uri() }
-- Cursor line (0-based) so a fresh section is inserted there.
local args = { uri = buf_uri(), line = vim.api.nvim_win_get_cursor(0)[1] - 1 }
if tag and tag ~= '' then
args.tag = tag
end
+126 -10
View File
@@ -35,10 +35,19 @@ M.defaults = {
-- custom_wiki2html, custom_wiki2html_args, base_url,
-- toc_header, toc_header_level, links_header, links_header_level,
-- tags_header, tags_header_level,
-- html_header_numbering, html_header_numbering_sym,
-- listsyms, listsym_rejected, listsyms_propagate, list_margin,
-- links_space_char
wikis = nil,
-- TOC defaults (per-wiki, also accepted as top-level for single-wiki setups)
toc_header = 'Contents',
toc_header_level = 1,
-- HTML export defaults (per-wiki, also accepted as top-level for single-wiki setups)
html_header_numbering = 0,
html_header_numbering_sym = '',
-- Per-buffer glue. Each subgroup mirrors vimwiki's
-- `g:vimwiki_key_mappings` shape and can be flipped off
-- independently to suppress that group of keymaps.
@@ -94,19 +103,126 @@ M.defaults = {
M.options = vim.deepcopy(M.defaults)
-- The raw table the user passed to setup(), kept separate from the
-- defaults-merged `options`. Used to tell an explicitly-set value apart from
-- a built-in default — e.g. so the LSP client only treats a per-wiki key as a
-- global default when the user actually set it.
M.user = {}
function M.apply(user)
M.user = user or {}
M.options = vim.tbl_deep_extend('force', M.defaults, user or {})
end
-- ===== Upstream vimwiki config compatibility (drop-in) =====
--
-- Single source of truth for the configured wikis, shared by the LSP payload
-- (lsp.lua) and the buffer-side commands. Mirrors autoload/nuwiki/config.vim so
-- a user who keeps their original vim.g.vimwiki_list + g:vimwiki_* config (e.g.
-- swapping the vimwiki plugin for nuwiki under lazy.nvim) needs no rewrite.
-- Native config (setup() wikis / g:nuwiki_*) always wins.
-- Upstream vimwiki per-wiki dict key -> nuwiki/server key.
local VIMWIKI_WIKI_MAP = {
path = 'root',
path_html = 'html_path',
template_path = 'template_path',
template_default = 'template_default',
template_ext = 'template_ext',
css_name = 'css_name',
auto_export = 'auto_export',
auto_toc = 'auto_toc',
syntax = 'syntax',
ext = 'file_extension',
index = 'index',
diary_rel_path = 'diary_rel_path',
diary_index = 'diary_index',
name = 'name',
}
-- Per-wiki settings that double as wiki-wide global defaults.
local SCALAR_GLOBAL_KEYS = {
'toc_header', 'toc_header_level', 'toc_link_format',
'links_header', 'links_header_level',
'tags_header', 'tags_header_level',
'html_header_numbering', 'html_header_numbering_sym',
'links_space_char', 'list_margin',
'listsyms', 'listsym_rejected',
'auto_export', 'auto_toc',
'auto_generate_links', 'auto_generate_tags', 'auto_diary_index',
}
-- Resolve the global scalar defaults applied to every wiki. Priority (low to
-- high): g:vimwiki_<key>, g:nuwiki_<key>, explicit setup() value. Built-in
-- defaults are excluded (reads M.user, not M.options) so they don't get folded
-- onto every wiki and override per-wiki values.
function M.scalar_globals()
local g = {}
for _, key in ipairs(SCALAR_GLOBAL_KEYS) do
if vim.g['vimwiki_' .. key] ~= nil then
g[key] = vim.g['vimwiki_' .. key]
end
if vim.g['nuwiki_' .. key] ~= nil then
g[key] = vim.g['nuwiki_' .. key]
end
if M.user[key] ~= nil then
g[key] = M.user[key]
end
end
return g
end
-- Translate vim.g.vimwiki_list into nuwiki's wiki list (server key names),
-- folding the global scalars into each entry. Returns {} when unset/malformed.
local function wikis_from_vimwiki(globals)
local list = vim.g.vimwiki_list
if type(list) ~= 'table' or vim.tbl_isempty(list) then
return {}
end
local out = {}
for _, vw in ipairs(list) do
if type(vw) == 'table' then
local w = vim.tbl_extend('force', {}, globals)
for src, dst in pairs(VIMWIKI_WIKI_MAP) do
if vw[src] ~= nil then
w[dst] = vw[src]
end
end
if w.root ~= nil then
table.insert(out, w)
end
end
end
return out
end
-- The configured wikis as normalized dicts (nuwiki/server key names), with the
-- global scalars folded in (per-wiki value wins). Priority:
-- 1. setup() opts.wikis
-- 2. g:nuwiki_wikis
-- 3. g:vimwiki_list (upstream drop-in)
-- Returns {} when none is set — callers fall back to the single-wiki shorthand.
function M.wikis()
local globals = M.scalar_globals()
local native = M.options.wikis or vim.g.nuwiki_wikis
if type(native) == 'table' and #native > 0 then
local out = {}
for i, w in ipairs(native) do
out[i] = vim.tbl_extend('force', globals, w)
end
return out
end
return wikis_from_vimwiki(globals)
end
-- Resolve the config for wiki #n (0-based) without touching the LSP.
-- Priority: setup() opts.wikis → g:nuwiki_wikis (VimL) → scalar opts/g: vars.
-- Returns { name, root, ext, idx, diary_rel, diary_idx }.
-- Returns { name, root, ext, idx, diary_rel, diary_idx, space }.
function M.wiki_cfg(n)
local opts = M.options
local wikis = opts.wikis or vim.g.nuwiki_wikis or nil
local w = wikis and wikis[(n or 0) + 1] or nil
local root = (w and w.root) or opts.wiki_root or vim.g.nuwiki_wiki_root or '~/vimwiki'
local ext = (w and w.file_extension) or opts.file_extension or vim.g.nuwiki_file_extension or '.wiki'
local wikis = M.wikis()
local w = wikis[(n or 0) + 1]
local root = (w and w.root) or M.options.wiki_root or vim.g.nuwiki_wiki_root or '~/vimwiki'
local ext = (w and w.file_extension) or M.options.file_extension
or vim.g.nuwiki_file_extension or '.wiki'
if not ext:match('^%.') then ext = '.' .. ext end
return {
name = (w and w.name) or vim.fn.fnamemodify(vim.fn.expand(root), ':t'),
@@ -115,7 +231,7 @@ function M.wiki_cfg(n)
idx = (w and w.index) or 'index',
diary_rel = (w and w.diary_rel_path) or vim.g.nuwiki_diary_rel_path or 'diary',
diary_idx = (w and w.diary_index) or 'diary',
space = (w and w.links_space_char) or opts.links_space_char
space = (w and w.links_space_char) or M.options.links_space_char
or vim.g.nuwiki_links_space_char or ' ',
}
end
@@ -123,9 +239,9 @@ end
-- The full list of configured wikis as wiki_cfg dicts. Falls back to a single
-- entry built from the scalar wiki_root config when no list is configured.
function M.wiki_list()
local wikis = M.options.wikis or vim.g.nuwiki_wikis or nil
local wikis = M.wikis()
local list = {}
if type(wikis) == 'table' and #wikis > 0 then
if #wikis > 0 then
for i = 1, #wikis do
list[i] = M.wiki_cfg(i - 1)
end
+11
View File
@@ -93,6 +93,17 @@ end
function M.setup(opts)
config.apply(opts or {})
-- Bridge a Lua-only `setup({ wikis = … })` config to VimL. The buffer-side
-- VimL helpers (autoload/nuwiki/config.vim) and the vimwiki compat shim
-- (autoload/vimwiki/vars.vim — used by third-party plugins such as
-- vimwiki-sync) read `g:nuwiki_wikis`, which can't see a config that only
-- lives in Lua. Publishing the resolved list keeps both worlds in sync. (A
-- `g:nuwiki_wikis` / `g:vimwiki_list` config is already VimL-visible, so this
-- only fires for the pure-`setup()` case.)
if type(config.options.wikis) == 'table' and #config.options.wikis > 0 then
vim.g.nuwiki_wikis = config.wikis()
end
-- Neovim 0.7+ resolves filetypes via `vim.filetype.match` before any
-- `ftdetect/*.vim` autocmd fires, and its bundled rule for `*.wiki`
-- returns `mediawiki`. Register an authoritative rule here so this
+4 -1
View File
@@ -74,8 +74,11 @@ local function download_release(dest)
if vim.fn.executable('curl') ~= 1 or vim.fn.executable('tar') ~= 1 then
return false
end
-- Gitea serves the most recent release under the `latest` tag segment
-- (`/releases/download/latest/<asset>`). Note this is NOT GitHub's
-- `/releases/latest/download/<asset>` shape, which Gitea 404s.
local url = string.format(
'https://code.gfran.co/gffranco/nuwiki/releases/latest/download/nuwiki-ls-%s.tar.gz',
'https://code.gfran.co/gffranco/nuwiki/releases/download/latest/nuwiki-ls-%s.tar.gz',
target
)
local archive = vim.fn.tempname() .. '.tar.gz'
+14 -5
View File
@@ -14,13 +14,22 @@ local function command()
return { install.expected_path() }
end
-- Merge g:nuwiki_wikis (VimL config) into opts.wikis when the user has not
-- passed wikis through setup(). This lets users who configure via a .vim
-- file avoid duplicating the list in their Lua setup() call.
-- Build the server config payload. The wiki list comes from the shared
-- resolver (config.wikis) — native setup() wikis, g:nuwiki_wikis, or an
-- upstream g:vimwiki_list, with global scalar defaults already folded — so the
-- payload and the buffer-side commands agree on which wikis exist.
local function resolved_opts()
local opts = config.options
if not opts.wikis and vim.g.nuwiki_wikis then
opts = vim.tbl_extend('force', opts, { wikis = vim.g.nuwiki_wikis })
local wikis = config.wikis()
if #wikis > 0 then
opts = vim.tbl_extend('force', opts, { wikis = wikis })
else
-- Single-wiki shorthand: fold the global scalars into the top-level payload
-- so the server's single-wiki desugaring honours them.
local globals = config.scalar_globals()
if not vim.tbl_isempty(globals) then
opts = vim.tbl_extend('keep', opts, globals)
end
end
return opts
end
+6 -1
View File
@@ -15,7 +15,7 @@ let g:loaded_nuwiki = 1
" Plugin version, surfaced by :VimwikiShowVersion / :NuwikiShowVersion.
" Set before the Neovim early-return below so both clients see the global.
if !exists('g:nuwiki_version')
let g:nuwiki_version = '0.1.0'
let g:nuwiki_version = '0.4.2'
endif
" Resolve the plugin root NOW, while this script is being sourced and
@@ -37,6 +37,11 @@ endif
augroup nuwiki_calendar_detect
autocmd!
autocmd FileType vimwiki call s:nuwiki_setup_calendar()
" Track the wiki of the active buffer so the calendar diaries into the wiki
" you're editing (not always the first one). FileType covers initial load;
" BufEnter covers switching back to an already-open wiki buffer.
autocmd FileType vimwiki call nuwiki#diary#track_wiki(expand('%:p'))
autocmd BufEnter * if &filetype ==# 'vimwiki' | call nuwiki#diary#track_wiki(expand('%:p')) | endif
augroup END
function! s:nuwiki_setup_calendar() abort
if !get(g:, 'nuwiki_use_calendar', 1) || get(g:, 'nuwiki_no_calendar', 0)
+14 -1
View File
@@ -3,6 +3,14 @@
"
" Invoked by Dein / vim-plug build hooks:
" vim -e -s -c "source scripts/download_bin.vim" -c "q"
"
" That invocation runs in 'compatible' mode, where leading-backslash line
" continuations aren't recognised (they'd raise E10, and any error in silent
" Ex mode makes Vim exit non-zero — a spurious build failure). Reset
" 'cpoptions' to the Vim default for the duration so the script parses, and
" restore it at the end.
let s:cpo_save = &cpo
set cpo&vim
let s:plugin_dir = fnamemodify(resolve(expand('<sfile>:p')), ':h:h')
let s:bin_dir = s:plugin_dir . '/bin'
@@ -56,7 +64,10 @@ function! s:download_release() abort
if !executable('curl') || !executable('tar')
return 0
endif
let l:url = 'https://code.gfran.co/gffranco/nuwiki/releases/latest/download/nuwiki-ls-'
" Gitea serves the most recent release under the `latest` tag segment
" (/releases/download/latest/<asset>). NOT GitHub's
" /releases/latest/download/<asset> shape, which Gitea 404s.
let l:url = 'https://code.gfran.co/gffranco/nuwiki/releases/download/latest/nuwiki-ls-'
\ . l:target . '.tar.gz'
let l:archive = tempname() . '.tar.gz'
call system('curl -fsSL -o ' . shellescape(l:archive) . ' ' . shellescape(l:url))
@@ -86,3 +97,5 @@ else
echohl ErrorMsg | echom 'nuwiki: install failed' | echohl None
endif
endif
let &cpo = s:cpo_save
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env bash
# scripts/set-version.sh — stamp a release version into every hardcoded spot.
#
# This is the single source of truth for *where* the version lives. The
# release pipeline (.gitea/workflows/release.yaml) calls it with the
# workflow-dispatch input; run it the same way to prepare a release by hand:
#
# scripts/set-version.sh 0.4.0
#
# It rewrites:
# 1. Cargo.toml — [workspace.package] version
# 2. crates/nuwiki-lsp/Cargo.toml — nuwiki-core path-dep version pin
# 3. crates/nuwiki-ls/Cargo.toml — nuwiki-lsp path-dep version pin
# 4. plugin/nuwiki.vim — g:nuwiki_version (the Lua client reads it too)
# 5. Cargo.lock — the version of our three own crates
#
# Cargo.lock is patched directly (awk) so this needs no cargo/toolchain —
# the only thing that changes for our crates is their version string.
set -euo pipefail
ver="${1:-}"
ver="${ver#v}" # tolerate a leading "v"
if ! printf '%s' "$ver" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.-]+)?$'; then
echo "usage: $0 <semver> e.g. $0 0.4.0" >&2
exit 2
fi
root="$(cd "$(dirname "$0")/.." && pwd)"
cd "$root"
# 1. Workspace package version (root Cargo.toml, [workspace.package]).
sed -i -E "s/^version = \"[0-9][^\"]*\"/version = \"$ver\"/" Cargo.toml
# 2-3. Internal path-dep pins must track the workspace version.
sed -i -E "s|(nuwiki-core = \{ path = \"\.\./nuwiki-core\", version = )\"[^\"]*\"|\1\"$ver\"|" \
crates/nuwiki-lsp/Cargo.toml
sed -i -E "s|(nuwiki-lsp = \{ path = \"\.\./nuwiki-lsp\", version = )\"[^\"]*\"|\1\"$ver\"|" \
crates/nuwiki-ls/Cargo.toml
# 4. VimL client version global (the Lua client reads this same g: var).
sed -i -E "s/(let g:nuwiki_version = ')[^']*(')/\1$ver\2/" plugin/nuwiki.vim
# 5. Cargo.lock entries for our own crates. Each [[package]] block lists the
# name line immediately followed by the version line; rewrite only those.
for crate in nuwiki-core nuwiki-lsp nuwiki-ls; do
awk -v c="$crate" -v v="$ver" '
$0 == "name = \"" c "\"" {
print
if (getline > 0) { sub(/version = "[^"]*"/, "version = \"" v "\"") }
print
next
}
{ print }
' Cargo.lock > Cargo.lock.tmp && mv Cargo.lock.tmp Cargo.lock
done
echo "Set version to $ver"