6 Commits

Author SHA1 Message Date
gffranco f6a543320e fix: use artifact v3 for GHES compatibility
Gitea (GHES) does not support upload-artifact@v4+.
Revert to v3 which is supported on GHES.
2026-06-03 22:16:40 -03:00
gffranco 678d62ac14 fix: use stable asset names without version in release workflow
CI / cargo fmt --check (pull_request) Successful in 31s
CI / cargo clippy (pull_request) Successful in 25s
CI / cargo test (pull_request) Successful in 32s
CI / editor keymaps (pull_request) Successful in 1m35s
Release / build aarch64-unknown-linux-musl (push) Failing after 1m2s
Release / build x86_64-unknown-linux-gnu (push) Failing after 1m1s
Release / build aarch64-unknown-linux-gnu (push) Failing after 1m9s
Release / build x86_64-unknown-linux-musl (push) Failing after 1m3s
Release / gitea release (push) Has been skipped
Rename release assets to remove the version so that
/releases/latest/download/nuwiki-ls-{target}.tar.gz always resolves
correctly from the install scripts.
2026-06-03 22:02:22 -03:00
gffranco d93637bb48 release: fix restore-keys pipe syntax for Gitea 2026-06-03 22:02:22 -03:00
gffranco 01b936fffc ci: fix restore-keys to use pipe syntax (Gitea doesn't support YAML list) 2026-06-03 22:02:22 -03:00
gffranco 5d3ce2fe0b ci: remove concurrency key (not supported by Gitea Actions) 2026-06-03 22:02:22 -03:00
gffranco d188b09fde 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:02:22 -03:00
50 changed files with 281 additions and 2173 deletions
-6
View File
@@ -120,9 +120,3 @@ jobs:
run: ./development/tests/test-calendar-vim.sh run: ./development/tests/test-calendar-vim.sh
- name: run Vim vars-shim harness - name: run Vim vars-shim harness
run: ./development/tests/test-vars-vim.sh 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
+16 -105
View File
@@ -1,17 +1,9 @@
name: Release 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: on:
workflow_dispatch: push:
inputs: tags:
version: - 'v*'
description: 'Release version, no leading v (e.g. 0.4.0)'
required: true
type: string
permissions: permissions:
contents: write contents: write
@@ -22,77 +14,8 @@ env:
RUSTFLAGS: -D warnings RUSTFLAGS: -D warnings
jobs: 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: build:
name: build ${{ matrix.target }} name: build ${{ matrix.target }}
needs: prepare
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
fail-fast: false fail-fast: false
@@ -115,9 +38,6 @@ jobs:
rustflags: "-D warnings -C linker=rust-lld -C link-self-contained=yes" rustflags: "-D warnings -C linker=rust-lld -C link-self-contained=yes"
steps: steps:
- uses: actions/checkout@v4 - 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 - uses: dtolnay/rust-toolchain@1.83
with: with:
@@ -152,11 +72,10 @@ jobs:
- name: Package archive - name: Package archive
id: package id: package
env:
VERSION: ${{ needs.prepare.outputs.version }}
run: | run: |
set -euo pipefail set -euo pipefail
archive="nuwiki-ls-${VERSION}-${{ matrix.target }}.tar.gz" version="${GITHUB_REF_NAME#v}"
archive="nuwiki-ls-${version}-${{ matrix.target }}.tar.gz"
tar -czf "$archive" -C "target/${{ matrix.target }}/release" nuwiki-ls tar -czf "$archive" -C "target/${{ matrix.target }}/release" nuwiki-ls
# Use a stable name without version so /releases/latest/download/nuwiki-ls-{target}.tar.gz always resolves. # Use a stable name without version so /releases/latest/download/nuwiki-ls-{target}.tar.gz always resolves.
stable="nuwiki-ls-${{ matrix.target }}.tar.gz" stable="nuwiki-ls-${{ matrix.target }}.tar.gz"
@@ -172,14 +91,9 @@ jobs:
release: release:
name: gitea release name: gitea release
needs: [prepare, build] needs: build
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.prepare.outputs.tag }}
fetch-depth: 0
- name: Download all build artifacts - name: Download all build artifacts
# download-artifact@v3 nests each artifact under its own dir # download-artifact@v3 nests each artifact under its own dir
# (artifacts/<artifact-name>/<file>), so recurse. # (artifacts/<artifact-name>/<file>), so recurse.
@@ -198,20 +112,18 @@ jobs:
- name: Generate release notes - name: Generate release notes
id: notes id: notes
env:
TAG: ${{ needs.prepare.outputs.tag }}
run: | run: |
set -euo pipefail set -euo pipefail
git fetch --tags --force origin >/dev/null 2>&1 || true # Get commits since the previous tag (or the first commit if this is the first release).
# Previous tag = newest tag that isn't the one we just cut. prev_tag=$(git tag --sort=-version:refname | awk "NR==2" || echo "")
prev_tag=$(git tag --sort=-version:refname | grep -vxF "$TAG" | head -n1 || echo "")
if [ -n "$prev_tag" ]; then if [ -n "$prev_tag" ]; then
log=$(git log --oneline "$prev_tag..$TAG") log=$(git log --oneline "$prev_tag"..HEAD)
else else
log=$(git log --oneline "$TAG") log=$(git log --oneline --max-parents=0..HEAD)
fi fi
# Write notes to a file for later use.
cat > release-notes.txt <<EOF cat > release-notes.txt <<EOF
**Full Changelog**: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/compare/${prev_tag:-$(git rev-list --max-parents=0 HEAD)}...${TAG} **Full Changelog**: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/compare/${prev_tag:-$(git rev-list --max-parents=0 HEAD)}...${GITHUB_REF_NAME}
**Changes**: **Changes**:
$log $log
@@ -223,7 +135,7 @@ jobs:
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }} RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
GITEA_SERVER: ${{ github.server_url }} GITEA_SERVER: ${{ github.server_url }}
REPO: ${{ github.repository }} REPO: ${{ github.repository }}
TAG: ${{ needs.prepare.outputs.tag }} TAG: ${{ github.ref_name }}
run: | run: |
set -euo pipefail set -euo pipefail
if [ -z "${RELEASE_TOKEN:-}" ]; then if [ -z "${RELEASE_TOKEN:-}" ]; then
@@ -237,7 +149,7 @@ jobs:
existing=$(curl --fail --silent --show-error -o /dev/null -w "%{http_code}" \ existing=$(curl --fail --silent --show-error -o /dev/null -w "%{http_code}" \
-H "Authorization: token $RELEASE_TOKEN" \ -H "Authorization: token $RELEASE_TOKEN" \
"$GITEA_SERVER/api/v1/repos/$REPO/releases/tags/$TAG" \ "$GITEA_SERVER/api/v1/repos/$REPO/releases/tags/$TAG" \
2>/dev/null || true) || true)
if [ "$existing" = "200" ]; then if [ "$existing" = "200" ]; then
echo "Release for $TAG already exists — fetching existing id" echo "Release for $TAG already exists — fetching existing id"
release_id=$(curl --fail --silent --show-error \ release_id=$(curl --fail --silent --show-error \
@@ -245,9 +157,8 @@ jobs:
"$GITEA_SERVER/api/v1/repos/$REPO/releases/tags/$TAG" \ "$GITEA_SERVER/api/v1/repos/$REPO/releases/tags/$TAG" \
| jq -r '.id') | jq -r '.id')
else else
notes=$(cat release-notes.txt 2>/dev/null || echo "") payload=$(jq -n --arg tag "$TAG" --arg name "$TAG" \
payload=$(jq -n --arg tag "$TAG" --arg name "$TAG" --arg body "$notes" \ '{tag_name: $tag, name: $name, draft: false, prerelease: false}')
'{tag_name: $tag, name: $name, body: $body, draft: false, prerelease: false}')
release_id=$(curl --fail --silent --show-error \ release_id=$(curl --fail --silent --show-error \
-H "Authorization: token $RELEASE_TOKEN" \ -H "Authorization: token $RELEASE_TOKEN" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
Generated
+3 -3
View File
@@ -376,11 +376,11 @@ checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]] [[package]]
name = "nuwiki-core" name = "nuwiki-core"
version = "0.4.2" version = "0.1.0"
[[package]] [[package]]
name = "nuwiki-ls" name = "nuwiki-ls"
version = "0.4.2" version = "0.1.0"
dependencies = [ dependencies = [
"nuwiki-lsp", "nuwiki-lsp",
"tokio", "tokio",
@@ -388,7 +388,7 @@ dependencies = [
[[package]] [[package]]
name = "nuwiki-lsp" name = "nuwiki-lsp"
version = "0.4.2" version = "0.1.0"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"dashmap 6.1.0", "dashmap 6.1.0",
+1 -1
View File
@@ -7,7 +7,7 @@ members = [
] ]
[workspace.package] [workspace.package]
version = "0.4.2" version = "0.1.0"
edition = "2021" edition = "2021"
rust-version = "1.83" rust-version = "1.83"
authors = ["Gabriel Fróes Franco <gffranco@gmail.com>"] authors = ["Gabriel Fróes Franco <gffranco@gmail.com>"]
+9 -37
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 Older releases fall back to a `vim.lsp.start` autocmd but aren't
officially supported. officially supported.
- **Vim 9+** — needs a third-party LSP client: - **Vim 9+** — needs a third-party LSP client:
[`vim-lsp`](https://github.com/prabirshrestha/vim-lsp) or [`vim-lsp`](https://github.com/prabirshrestha/vim-lsp) (recommended,
[`coc.nvim`](https://github.com/neoclide/coc.nvim) — both auto-registered) or [`coc.nvim`](https://github.com/neoclide/coc.nvim)
auto-registered, no manual config (see [Vim LSP client (one-time `coc-settings.json` entry — see [Vim LSP client
setup](#vim-lsp-client-setup)). setup](#vim-lsp-client-setup)).
- **Rust toolchain (1.83+ stable)** — only needed when building the - **Rust toolchain (1.83+ stable)** — only needed when building the
binary from source; pre-built release downloads skip this. binary from source; pre-built release downloads skip this.
@@ -145,18 +145,10 @@ auto-enables it. Your wiki settings (`g:nuwiki_wiki_root`,
`g:nuwiki_wikis`, …) are forwarded as the server's initialization `g:nuwiki_wikis`, …) are forwarded as the server's initialization
options. options.
**coc.nvim**also zero configuration. nuwiki registers itself with **coc.nvim**[`coc.nvim`](https://github.com/neoclide/coc.nvim) reads
[`coc.nvim`](https://github.com/neoclide/coc.nvim) programmatically its server list from `coc-settings.json`, which the plugin can't edit
(`coc#config('languageserver.nuwiki', …)`) when the first `.wiki` buffer on your behalf, so add the entry once yourself (open it with
loads, forwarding the same wiki settings (`g:nuwiki_wiki_root`, `:CocConfig`):
`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 ```json
{ {
@@ -345,14 +337,6 @@ The `<Leader>w` prefix in the `wiki_prefix`, `links`, and `html_export` rows is
#### Per-wiki keys (`wikis[]`) #### 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 | | Key | Type | Default | Accepted values |
|-----|------|---------|-----------------| |-----|------|---------|-----------------|
| `name` | string | — | any label | | `name` | string | — | any label |
@@ -676,20 +660,8 @@ match vimwiki. To migrate:
1. Drop the original `vimwiki` plugin from your config. 1. Drop the original `vimwiki` plugin from your config.
2. Install nuwiki. 2. Install nuwiki.
3. Keep your existing config, or point `wiki_root` / `wikis[i].root` at 3. Point `wiki_root` (or each `wikis[i].root`) at your existing
your directory. 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 Existing pages, diary entries, tags, and templates work without
modification. The first workspace scan after launch may show an empty modification. The first workspace scan after launch may show an empty
+16 -22
View File
@@ -588,13 +588,14 @@ endfunction
" open their wiki from any buffer. Files are opened directly from config; " open their wiki from any buffer. Files are opened directly from config;
" the LSP auto-starts via the FileType autocmd once the buffer loads. " the LSP auto-starts via the FileType autocmd once the buffer loads.
" Return a dict with {root, ext, idx, diary_rel, diary_idx, space} for wiki #n " Return a dict with {root, ext, idx, diary_rel, diary_idx} for wiki #n
" (0-based). Resolves through nuwiki#config#wikis() — so g:nuwiki_wikis AND an " (0-based). Prefers g:nuwiki_wikis[n]; falls back to scalar g:nuwiki_*
" upstream g:vimwiki_list both work — and falls back to the scalar g:nuwiki_* " vars and finally to built-in defaults.
" vars (single-wiki shorthand) and finally built-in defaults.
function! s:wiki_cfg(n) abort function! s:wiki_cfg(n) abort
let l:wikis = nuwiki#config#wikis() let l:w = {}
let l:w = (a:n >= 0 && a:n < len(l:wikis)) ? l:wikis[a:n] : {} if exists('g:nuwiki_wikis') && len(g:nuwiki_wikis) > a:n
let l:w = g:nuwiki_wikis[a:n]
endif
let l:root = expand(get(l:w, 'root', let l:root = expand(get(l:w, 'root',
\ get(g:, 'nuwiki_wiki_root', '~/vimwiki'))) \ get(g:, 'nuwiki_wiki_root', '~/vimwiki')))
let l:ext = get(l:w, 'file_extension', let l:ext = get(l:w, 'file_extension',
@@ -611,15 +612,14 @@ function! s:wiki_cfg(n) abort
endfunction endfunction
" Build the list of configured wikis as {name, root, ext, idx, diary_rel, " Build the list of configured wikis as {name, root, ext, idx, diary_rel,
" diary_idx, space} dicts, straight from config — no LSP round-trip. Drives the " diary_idx} dicts, straight from config — no LSP round-trip. Drives the wiki
" wiki picker so it works before any wiki buffer (and therefore the server) " picker so it works before any wiki buffer (and therefore the server) exists.
" exists. Falls back to a single entry from the scalar `g:nuwiki_wiki_root`. " Falls back to a single entry from the scalar `g:nuwiki_wiki_root` config.
function! nuwiki#commands#wiki_list() abort function! nuwiki#commands#wiki_list() abort
let l:wikis = nuwiki#config#wikis()
let l:list = [] let l:list = []
if !empty(l:wikis) if exists('g:nuwiki_wikis') && !empty(g:nuwiki_wikis)
let l:n = 0 let l:n = 0
for l:w in l:wikis for l:w in g:nuwiki_wikis
let l:c = s:wiki_cfg(l:n) let l:c = s:wiki_cfg(l:n)
let l:c['name'] = get(l:w, 'name', fnamemodify(l:c.root, ':t')) let l:c['name'] = get(l:w, 'name', fnamemodify(l:c.root, ':t'))
call add(l:list, l:c) call add(l:list, l:c)
@@ -725,23 +725,18 @@ endfunction
" ===== Generation + workspace ===== " ===== Generation + workspace =====
function! nuwiki#commands#toc_generate() abort function! nuwiki#commands#toc_generate() abort
" Send the cursor line (0-based) so a fresh TOC is inserted there. call s:exec('nuwiki.toc.generate', [{'uri': s:buf_uri()}])
call s:exec('nuwiki.toc.generate',
\ [{'uri': s:buf_uri(), 'line': line('.') - 1}])
endfunction endfunction
" `:VimwikiGenerateLinks [rel_path]` — with no arg, list every page; with a " `:VimwikiGenerateLinks [rel_path]` — with no arg, list every page; with a
" rel-path arg, scope the generated links to that subtree (server-side " rel-path arg, scope the generated links to that subtree (server-side
" `generateForPath`). Matches upstream's optional path argument. " `generateForPath`). Matches upstream's optional path argument.
function! nuwiki#commands#links_generate(...) abort function! nuwiki#commands#links_generate(...) abort
let l:path = a:0 >= 1 ? a:1 : '' 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 !=# '' if l:path !=# ''
call s:exec('nuwiki.links.generateForPath', call s:exec('nuwiki.links.generateForPath',
\ [{'uri': s:buf_uri(), 'path': l:path, 'line': l:line}]) \ [{'uri': s:buf_uri(), 'path': l:path}])
else else
call s:exec('nuwiki.links.generate', call s:exec('nuwiki.links.generate', [{'uri': s:buf_uri()}])
\ [{'uri': s:buf_uri(), 'line': l:line}])
endif endif
endfunction endfunction
function! nuwiki#commands#check_links(...) abort function! nuwiki#commands#check_links(...) abort
@@ -803,8 +798,7 @@ function! nuwiki#commands#tags_search(query) abort
endfunction endfunction
function! nuwiki#commands#tags_generate_links(tag) abort function! nuwiki#commands#tags_generate_links(tag) abort
" Cursor line (0-based) so a fresh section is inserted there. let l:args = { 'uri': s:buf_uri() }
let l:args = { 'uri': s:buf_uri(), 'line': line('.') - 1 }
if a:tag !=# '' if a:tag !=# ''
let l:args['tag'] = a:tag let l:args['tag'] = a:tag
endif endif
-103
View File
@@ -1,103 +0,0 @@
" 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
+10 -36
View File
@@ -3,33 +3,6 @@
" ===== Calendar-Vim Integration ===== " ===== 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) " Function: nuwiki#diary#calendar_action(day, month, year, week, dir)
" Called by calendar-vim when user presses Enter on a date. " 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 " day/month/year are numeric integers; dir is 'V' for a vertical split (any
@@ -39,8 +12,8 @@ function! nuwiki#diary#calendar_action(day, month, year, week, dir) abort
" Build YYYY-MM-DD from the parts passed by calendar-vim " 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) let l:date_str = printf('%04d-%02d-%02d', a:year, a:month, a:day)
" Wiki of the buffer the calendar was opened from (falls back to the first). " Get wiki configuration for the first wiki (index 0)
let l:c = s:wiki_cfg(s:current_wiki_idx) let l:c = s:wiki_cfg(0)
" Construct file path: {root}/{diary_rel}/{YYYY-MM-DD}.{ext} " 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 let l:file_path = l:c.root . '/' . l:c.diary_rel . '/' . l:date_str . l:c.ext
@@ -89,8 +62,8 @@ function! nuwiki#diary#calendar_sign(day, month, year) abort
" Build YYYY-MM-DD from the parts passed by calendar-vim " 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) let l:date_str = printf('%04d-%02d-%02d', a:year, a:month, a:day)
" Wiki of the buffer the calendar was opened from (falls back to the first). " Get wiki configuration for the first wiki (index 0)
let l:c = s:wiki_cfg(s:current_wiki_idx) let l:c = s:wiki_cfg(0)
" Construct expected file path " Construct expected file path
let l:file_path = l:c.root . '/' . l:c.diary_rel . '/' . l:date_str . l:c.ext let l:file_path = l:c.root . '/' . l:c.diary_rel . '/' . l:date_str . l:c.ext
@@ -102,12 +75,13 @@ function! nuwiki#diary#calendar_sign(day, month, year) abort
return '' return ''
endfunction endfunction
" Helper: get wiki configuration for wiki n (0-based). Resolves through " Helper: get wiki configuration for wiki n (0-based).
" nuwiki#config#wikis() so g:nuwiki_wikis AND an upstream g:vimwiki_list both " Mirrors the same logic used by nuwiki#commands#open_diary_path etc.
" work, falling back to the scalar g:nuwiki_* single-wiki shorthand.
function! s:wiki_cfg(n) abort function! s:wiki_cfg(n) abort
let l:wikis = nuwiki#config#wikis() let l:w = {}
let l:w = (a:n >= 0 && a:n < len(l:wikis)) ? l:wikis[a:n] : {} if exists('g:nuwiki_wikis') && len(g:nuwiki_wikis) > a:n
let l:w = g:nuwiki_wikis[a:n]
endif
let l:root = expand(get(l:w, 'root', let l:root = expand(get(l:w, 'root',
\ get(g:, 'nuwiki_wiki_root', '~/vimwiki'))) \ get(g:, 'nuwiki_wiki_root', '~/vimwiki')))
let l:ext = get(l:w, 'file_extension', let l:ext = get(l:w, 'file_extension',
+22 -71
View File
@@ -45,7 +45,22 @@ function! nuwiki#lsp#start() abort
endif endif
if exists(':CocConfig') == 2 || exists('*coc#config') if exists(':CocConfig') == 2 || exists('*coc#config')
call s:register_with_coc(l:bin) " 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
return return
endif endif
@@ -56,66 +71,6 @@ function! nuwiki#lsp#start() abort
echohl None echohl None
endfunction 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 function! s:bin_path() abort
if exists('g:nuwiki_binary_path') && filereadable(g:nuwiki_binary_path) if exists('g:nuwiki_binary_path') && filereadable(g:nuwiki_binary_path)
return g:nuwiki_binary_path return g:nuwiki_binary_path
@@ -134,16 +89,12 @@ function! s:settings() abort
\ 'link_severity': get(g:, 'nuwiki_link_severity', 'warn'), \ 'link_severity': get(g:, 'nuwiki_link_severity', 'warn'),
\ }, \ },
\ } \ }
" Wiki list comes from the shared resolver (nuwiki#config#wikis), so the " Include the per-wiki list when configured via g:nuwiki_wikis so the
" server payload and the buffer-side commands agree on which wikis exist — " server knows each wiki's root, diary path, etc. Without this it only
" native g:nuwiki_wikis or an upstream g:vimwiki_list, globals already folded. " sees wiki_root and resolves links relative to that directory instead
let l:wikis = nuwiki#config#wikis() " of the individual wiki's root.
if !empty(l:wikis) if exists('g:nuwiki_wikis') && !empty(g:nuwiki_wikis)
let l:cfg['wikis'] = l:wikis let l:cfg['wikis'] = g:nuwiki_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 endif
return l:cfg return l:cfg
endfunction endfunction
-4
View File
@@ -53,10 +53,6 @@ pub struct PreformattedNode {
pub span: Span, pub span: Span,
pub content: String, pub content: String,
pub language: Option<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)] #[derive(Debug, Clone, PartialEq, Eq)]
-17
View File
@@ -17,22 +17,6 @@ pub enum Keyword {
Stopped, 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)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum InlineNode { pub enum InlineNode {
Text(TextNode), Text(TextNode),
@@ -105,7 +89,6 @@ fn push_inline_text(inlines: &[InlineNode], out: &mut String) {
InlineNode::Superscript(s) => push_inline_text(&s.children, out), InlineNode::Superscript(s) => push_inline_text(&s.children, out),
InlineNode::Subscript(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::Color(c) => push_inline_text(&c.children, out),
InlineNode::Keyword(k) => out.push_str(k.keyword.label()),
InlineNode::WikiLink(w) => match &w.description { InlineNode::WikiLink(w) => match &w.description {
Some(d) => push_inline_text(d, out), Some(d) => push_inline_text(d, out),
None => { None => {
+35 -155
View File
@@ -59,11 +59,6 @@ pub struct HtmlRenderer {
/// vimwiki `html_header_numbering_sym`: a symbol appended after the /// vimwiki `html_header_numbering_sym`: a symbol appended after the
/// section number (e.g. `.` → `1.2. Heading`). Empty by default. /// section number (e.g. `.` → `1.2. Heading`). Empty by default.
header_numbering_sym: String, 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 /// vimwiki `valid_html_tags`: inline HTML tag names passed through to
/// export verbatim (instead of escaping `<`/`>`). Empty = escape all. /// export verbatim (instead of escaping `<`/`>`). Empty = escape all.
/// `span` is always allowed so colour spans render. /// `span` is always allowed so colour spans render.
@@ -95,7 +90,6 @@ impl HtmlRenderer {
color_template: "<span style=\"__STYLE__\">__CONTENT__</span>".to_string(), color_template: "<span style=\"__STYLE__\">__CONTENT__</span>".to_string(),
header_numbering: 0, header_numbering: 0,
header_numbering_sym: String::new(), header_numbering_sym: String::new(),
toc_header: String::new(),
valid_html_tags: Vec::new(), valid_html_tags: Vec::new(),
emoji_enable: false, emoji_enable: false,
text_ignore_newline: true, text_ignore_newline: true,
@@ -189,13 +183,6 @@ impl HtmlRenderer {
self.header_numbering_sym = sym.into(); self.header_numbering_sym = sym.into();
self 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 /// Running state for vimwiki-style HTML section numbering
@@ -278,26 +265,10 @@ impl HtmlRenderer {
// quotes) go through `render_block` and are left unnumbered, matching // quotes) go through `render_block` and are left unnumbered, matching
// upstream which only numbers document-level headers. // upstream which only numbers document-level headers.
let mut numberer = HeadingNumberer::new(self.header_numbering, &self.header_numbering_sym); 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 { for block in &doc.children {
if let BlockNode::Heading(n) = block { if let BlockNode::Heading(n) = block {
let level = n.level.clamp(1, 6); let number = numberer.prefix(n.level.clamp(1, 6));
let number = numberer.prefix(level); self.render_heading(n, &number, w)?;
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 { } else {
self.render_block(block, w)?; self.render_block(block, w)?;
} }
@@ -307,7 +278,7 @@ impl HtmlRenderer {
fn render_block(&self, block: &BlockNode, w: &mut dyn Write) -> io::Result<()> { fn render_block(&self, block: &BlockNode, w: &mut dyn Write) -> io::Result<()> {
match block { 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::Paragraph(n) => self.render_paragraph(n, w),
BlockNode::HorizontalRule(n) => self.render_hr(n, w), BlockNode::HorizontalRule(n) => self.render_hr(n, w),
BlockNode::Blockquote(n) => self.render_blockquote(n, w), BlockNode::Blockquote(n) => self.render_blockquote(n, w),
@@ -323,14 +294,13 @@ impl HtmlRenderer {
} }
fn render_tag(&self, n: &TagNode, w: &mut dyn Write) -> io::Result<()> { fn render_tag(&self, n: &TagNode, w: &mut dyn Write) -> io::Result<()> {
// `id="…"` (bare tag name, matching vimwiki) lets `[[Page#name]]` jump // `id="tag-…"` lets `[[Page#tag-name]]` jump to the rendered tag.
// to the rendered tag.
w.write_all(b"<div class=\"tags\">")?; w.write_all(b"<div class=\"tags\">")?;
for (i, name) in n.tags.iter().enumerate() { for (i, name) in n.tags.iter().enumerate() {
if i > 0 { if i > 0 {
w.write_all(b" ")?; w.write_all(b" ")?;
} }
w.write_all(b"<span class=\"tag\" id=\"")?; w.write_all(b"<span class=\"tag\" id=\"tag-")?;
write_escaped(name, w)?; write_escaped(name, w)?;
w.write_all(b"\">")?; w.write_all(b"\">")?;
write_escaped(name, w)?; write_escaped(name, w)?;
@@ -341,71 +311,26 @@ impl HtmlRenderer {
/// Render a heading. `number` is the optional section-number prefix /// Render a heading. `number` is the optional section-number prefix
/// (already including its trailing symbol and space, e.g. `"1.2. "`); /// (already including its trailing symbol and space, e.g. `"1.2. "`);
/// pass `""` for no numbering. `hier_id` is the hierarchical anchor id /// pass `""` for no numbering.
/// (parent anchors joined by `-`); pass `""` to default it to the flat id. fn render_heading(&self, n: &HeadingNode, number: &str, w: &mut dyn Write) -> io::Result<()> {
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 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); let anchor = crate::ast::inline_text(&n.children);
// Inside a heading, render TODO/DONE/… as plain text rather than a write!(w, "<h{level}{class} id=\"")?;
// 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_escaped(&anchor, w)?;
write!(w, "\">")?; write!(w, "\">")?;
if !number.is_empty() { if !number.is_empty() {
write_escaped(number, w)?; write_escaped(number, w)?;
} }
self.render_inlines(&body, w)?; self.render_inlines(&n.children, w)?;
write!(w, "</h{level}></div>")?; writeln!(w, "</h{level}>")
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<()> { fn render_paragraph(&self, n: &ParagraphNode, w: &mut dyn Write) -> io::Result<()> {
@@ -432,16 +357,16 @@ impl HtmlRenderer {
} }
fn render_preformatted(&self, n: &PreformattedNode, w: &mut dyn Write) -> io::Result<()> { fn render_preformatted(&self, n: &PreformattedNode, w: &mut dyn Write) -> io::Result<()> {
// vimwiki drops the verbatim fence text into the `<pre>` tag as raw match &n.language {
// attributes (`{{{python` → `<pre python>`), with no inner `<code>`. Some(lang) => {
// We mirror that so highlighters wired for vimwiki output still apply. w.write_all(b"<pre><code class=\"language-")?;
if n.attrs.is_empty() { write_escaped(lang, w)?;
w.write_all(b"<pre>")?; w.write_all(b"\">")?;
} else { }
write!(w, "<pre {}>", n.attrs)?; None => w.write_all(b"<pre><code>")?,
} }
write_escaped(&n.content, w)?; write_escaped(&n.content, w)?;
w.write_all(b"</pre>\n") w.write_all(b"</code></pre>\n")
} }
fn render_math_block(&self, n: &MathBlockNode, w: &mut dyn Write) -> io::Result<()> { fn render_math_block(&self, n: &MathBlockNode, w: &mut dyn Write) -> io::Result<()> {
@@ -758,16 +683,16 @@ impl HtmlRenderer {
// One class per keyword so stylesheets can colour them independently // One class per keyword so stylesheets can colour them independently
// (e.g. red TODO/FIXME/XXX vs green DONE/FIXED/STARTED). TODO keeps the // (e.g. red TODO/FIXME/XXX vs green DONE/FIXED/STARTED). TODO keeps the
// vimwiki `todo` class so stock vimwiki CSS still styles it. // vimwiki `todo` class so stock vimwiki CSS still styles it.
let class = match n.keyword { let (label, class) = match n.keyword {
Keyword::Todo => "todo", Keyword::Todo => ("TODO", "todo"),
Keyword::Done => "done", Keyword::Done => ("DONE", "done"),
Keyword::Started => "started", Keyword::Started => ("STARTED", "started"),
Keyword::Fixme => "fixme", Keyword::Fixme => ("FIXME", "fixme"),
Keyword::Fixed => "fixed", Keyword::Fixed => ("FIXED", "fixed"),
Keyword::Xxx => "xxx", Keyword::Xxx => ("XXX", "xxx"),
Keyword::Stopped => "stopped", Keyword::Stopped => ("STOPPED", "stopped"),
}; };
write!(w, "<span class=\"{class}\">{}</span>", n.keyword.label()) write!(w, "<span class=\"{class}\">{label}</span>")
} }
fn render_color(&self, n: &ColorNode, w: &mut dyn Write) -> io::Result<()> { fn render_color(&self, n: &ColorNode, w: &mut dyn Write) -> io::Result<()> {
@@ -944,51 +869,6 @@ fn table_spans(table: &TableNode) -> Vec<Vec<CellLayout>> {
/// wiki pages become `path.html`; interwiki links land in sibling /// wiki pages become `path.html`; interwiki links land in sibling
/// directories; diary entries land under `diary/`; file/local schemes /// directories; diary entries land under `diary/`; file/local schemes
/// use their literal path; anchor-only links collapse to a fragment. /// 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 { pub fn default_link_resolver(target: &LinkTarget) -> String {
if matches!(target.kind, LinkKind::AnchorOnly) { if matches!(target.kind, LinkKind::AnchorOnly) {
return match &target.anchor { return match &target.anchor {
+7 -39
View File
@@ -81,9 +81,6 @@ pub enum VimwikiTokenKind {
PreformattedOpen { PreformattedOpen {
language: Option<String>, language: Option<String>,
attrs: HashMap<String, 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, PreformattedClose,
PreformattedLine(String), PreformattedLine(String),
@@ -424,14 +421,7 @@ impl<'src> LexState<'src> {
// pairs separated by spaces. Keep parsing forgiving. // pairs separated by spaces. Keep parsing forgiving.
let after = trimmed[3..].trim(); let after = trimmed[3..].trim();
let (language, attrs) = parse_fence_attrs(after); let (language, attrs) = parse_fence_attrs(after);
// vimwiki keeps everything after `{{{` (minus trailing whitespace) and let kind = VimwikiTokenKind::PreformattedOpen { language, attrs };
// 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)); let span = Span::new(self.pos_in_line(indent), self.line_end_pos(line));
self.push(kind, span); self.push(kind, span);
self.mode = BlockMode::Preformatted; self.mode = BlockMode::Preformatted;
@@ -526,9 +516,10 @@ impl<'src> LexState<'src> {
if level == 0 { if level == 0 {
return false; return false;
} }
// vimwiki accepts both spaced (`== H ==`) and spaceless (`==H==`) // Must be followed by at least one space.
// headings, so we don't require a space after the opening `=`s. The if leading_ws + level >= bytes.len() || bytes[leading_ws + level] != b' ' {
// title is still trimmed of one optional space per side below. return false;
}
// Trailing `=`s of the same level. // Trailing `=`s of the same level.
let trimmed_end = line.trim_end_matches([' ', '\t']); let trimmed_end = line.trim_end_matches([' ', '\t']);
@@ -874,10 +865,8 @@ impl<'src> LexState<'src> {
flush(self, &mut buf, &mut buf_start, abs_col); flush(self, &mut buf, &mut buf_start, abs_col);
self.emit(VimwikiTokenKind::WikiLinkOpen, abs_col, abs_col + 2); self.emit(VimwikiTokenKind::WikiLinkOpen, abs_col, abs_col + 2);
i += 2; i += 2;
// Lex until the closing `]]`. A single `]` inside the body — // Lex until `]]` (absolute index into `slice`).
// e.g. a `[TICKET-1]` in the description — is literal text, so let close_abs_in_slice = after_open.find("]]").map(|c| i + c);
// 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()); let inner_end = close_abs_in_slice.unwrap_or(slice.len());
self.lex_link_body(slice, i, line_col_start, inner_end, true); self.lex_link_body(slice, i, line_col_start, inner_end, true);
i = inner_end; i = inner_end;
@@ -1063,27 +1052,6 @@ impl<'src> LexState<'src> {
/// Parse the bit after `{{{` on a fence line. Format is forgiving: /// Parse the bit after `{{{` on a fence line. Format is forgiving:
/// `lang` (single word) followed by optional `key=value` pairs separated /// `lang` (single word) followed by optional `key=value` pairs separated
/// by spaces. /// 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>) { fn parse_fence_attrs(s: &str) -> (Option<String>, HashMap<String, String>) {
let mut attrs = HashMap::new(); let mut attrs = HashMap::new();
let mut language: Option<String> = None; let mut language: Option<String> = None;
@@ -374,8 +374,8 @@ impl<'a> ParseState<'a> {
let open = self.advance().unwrap(); let open = self.advance().unwrap();
let span_start = open.span.start; let span_start = open.span.start;
let mut span_end = open.span.end; let mut span_end = open.span.end;
let (language, attrs) = match &open.kind { let language = match &open.kind {
K::PreformattedOpen { language, raw, .. } => (language.clone(), raw.clone()), K::PreformattedOpen { language, .. } => language.clone(),
_ => unreachable!(), _ => unreachable!(),
}; };
let mut content = String::new(); let mut content = String::new();
@@ -390,7 +390,6 @@ impl<'a> ParseState<'a> {
span: Span::new(span_start, span_end), span: Span::new(span_start, span_end),
content, content,
language, language,
attrs,
}); });
} }
K::PreformattedLine(s) => { K::PreformattedLine(s) => {
@@ -414,7 +413,6 @@ impl<'a> ParseState<'a> {
span: Span::new(span_start, span_end), span: Span::new(span_start, span_end),
content, content,
language, language,
attrs,
}) })
} }
+10 -123
View File
@@ -63,11 +63,7 @@ fn heading_levels() {
for level in 1..=6 { for level in 1..=6 {
let bars = "=".repeat(level); let bars = "=".repeat(level);
let out = render(&format!("{bars} Title {bars}\n")); let out = render(&format!("{bars} Title {bars}\n"));
// vimwiki structure: <div id="hier"><h{l} id="flat" class="header"> assert!(out.starts_with(&format!("<h{level} id=\"Title\">Title</h{level}>")));
// <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}");
} }
} }
@@ -77,64 +73,6 @@ fn centered_heading_gets_centered_class() {
assert!(out.contains("<h1 class=\"centered\" id=\"T\">T</h1>")); 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] #[test]
fn paragraph_wraps_inline_content() { fn paragraph_wraps_inline_content() {
let out = render("Hello world\n"); let out = render("Hello world\n");
@@ -156,26 +94,11 @@ fn blockquote_lines_become_paragraphs() {
} }
#[test] #[test]
fn preformatted_block_emits_pre_with_raw_fence_attrs() { fn preformatted_block_emits_pre_code_with_language_class() {
// 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"); let out = render("{{{rust\nfn main() {}\n}}}\n");
assert!(out.contains("<pre rust>"), "got: {out}"); assert!(out.contains("<pre><code class=\"language-rust\">"));
assert!(out.contains("fn main() {}")); assert!(out.contains("fn main() {}"));
assert!(out.contains("</pre>")); assert!(out.contains("</code></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] #[test]
@@ -321,10 +244,7 @@ fn heading_id_matches_anchor_link_href() {
// for "exported HTML anchor links don't work". // for "exported HTML anchor links don't work".
let out = render("= My Section =\n\n[[#My Section]]\n"); let out = render("= My Section =\n\n[[#My Section]]\n");
assert!( assert!(
out.contains( out.contains("<h1 id=\"My Section\">My Section</h1>"),
"<div id=\"My Section\"><h1 id=\"My Section\" class=\"header\">\
<a href=\"#My Section\">My Section</a></h1></div>"
),
"heading id: {out}" "heading id: {out}"
); );
assert!( assert!(
@@ -333,39 +253,6 @@ 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] #[test]
fn interwiki_numbered_link() { fn interwiki_numbered_link() {
let out = render("[[wiki1:Page]]\n"); let out = render("[[wiki1:Page]]\n");
@@ -428,7 +315,7 @@ fn template_substitutes_content_and_title() {
); );
let out = renderer.render_to_string(&doc).unwrap(); let out = renderer.render_to_string(&doc).unwrap();
assert!(out.contains("<title>My Page</title>")); assert!(out.contains("<title>My Page</title>"));
assert!(out.contains("<body><div id=\"Hello\"><h1 id=\"Hello\" class=\"header\"><a href=\"#Hello\">Hello</a></h1></div>")); assert!(out.contains("<body><h1 id=\"Hello\">Hello</h1>"));
} }
#[test] #[test]
@@ -437,7 +324,7 @@ fn template_with_missing_title_substitutes_empty_string() {
let renderer = HtmlRenderer::new().with_template("<title>{{title}}</title>{{content}}"); let renderer = HtmlRenderer::new().with_template("<title>{{title}}</title>{{content}}");
let out = renderer.render_to_string(&doc).unwrap(); let out = renderer.render_to_string(&doc).unwrap();
assert!(out.contains("<title></title>")); assert!(out.contains("<title></title>"));
assert!(out.contains("<div id=\"Heading\"><h1 id=\"Heading\" class=\"header\"><a href=\"#Heading\">Heading</a></h1></div>")); assert!(out.contains("<h1 id=\"Heading\">Heading</h1>"));
} }
#[test] #[test]
@@ -453,7 +340,7 @@ fn template_substitutes_vimwiki_percent_placeholders() {
let out = renderer.render_to_string(&doc).unwrap(); let out = renderer.render_to_string(&doc).unwrap();
assert!(out.contains("<title>My Page</title>"), "title: {out}"); assert!(out.contains("<title>My Page</title>"), "title: {out}");
assert!( assert!(
out.contains("<body><div id=\"Hello\"><h1 id=\"Hello\" class=\"header\"><a href=\"#Hello\">Hello</a></h1></div>"), out.contains("<body><h1 id=\"Hello\">Hello</h1>"),
"content: {out}" "content: {out}"
); );
assert!(out.contains("href=\"../style.css\""), "root_path: {out}"); assert!(out.contains("href=\"../style.css\""), "root_path: {out}");
@@ -547,7 +434,7 @@ fn x() {}
let out = HtmlRenderer::new().render_to_string(&doc).unwrap(); let out = HtmlRenderer::new().render_to_string(&doc).unwrap();
// A few sanity checks; the full output is exercised piece-by-piece above. // A few sanity checks; the full output is exercised piece-by-piece above.
for needle in [ for needle in [
"<div id=\"Heading\"><h1 id=\"Heading\" class=\"header\"><a href=\"#Heading\">Heading</a></h1></div>", "<h1 id=\"Heading\">Heading</h1>",
"<strong>bold</strong>", "<strong>bold</strong>",
"<em>italic</em>", "<em>italic</em>",
"<code>code</code>", "<code>code</code>",
@@ -555,7 +442,7 @@ fn x() {}
"<blockquote>", "<blockquote>",
"<hr>", "<hr>",
"<table>", "<table>",
"<pre rust>", "<pre><code class=\"language-rust\">",
] { ] {
assert!( assert!(
out.contains(needle), out.contains(needle),
+1 -35
View File
@@ -85,32 +85,6 @@ fn centered_heading_is_marked_centered() {
assert!(matches!(lexed[2], HeadingClose)); 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] #[test]
fn heading_with_inline_bold() { fn heading_with_inline_bold() {
let lexed = lex("== Hello *world* ==\n"); let lexed = lex("== Hello *world* ==\n");
@@ -205,13 +179,7 @@ fn preformatted_block_parses_language() {
#[test] #[test]
fn preformatted_block_parses_attrs() { fn preformatted_block_parses_attrs() {
let lexed = lex("{{{rust class=\"hl\" key=val\nx\n}}}\n"); let lexed = lex("{{{rust class=\"hl\" key=val\nx\n}}}\n");
let PreformattedOpen { let PreformattedOpen { language, attrs } = &lexed[0] else {
language,
attrs,
raw,
..
} = &lexed[0]
else {
panic!("expected PreformattedOpen"); panic!("expected PreformattedOpen");
}; };
assert_eq!(language.as_deref(), Some("rust")); assert_eq!(language.as_deref(), Some("rust"));
@@ -219,8 +187,6 @@ fn preformatted_block_parses_attrs() {
expected.insert("class".into(), "hl".into()); expected.insert("class".into(), "hl".into());
expected.insert("key".into(), "val".into()); expected.insert("key".into(), "val".into());
assert_eq!(attrs, &expected); assert_eq!(attrs, &expected);
// The verbatim fence text is preserved for the HTML renderer.
assert_eq!(raw, "rust class=\"hl\" key=val");
} }
// ===== Math block ===== // ===== Math block =====
+2 -3
View File
@@ -190,9 +190,8 @@ fn file_tags_are_deduped_in_metadata() {
fn renderer_emits_div_with_tag_spans() { fn renderer_emits_div_with_tag_spans() {
let html = render(":todo:done:\n"); let html = render(":todo:done:\n");
assert!(html.contains("<div class=\"tags\">")); assert!(html.contains("<div class=\"tags\">"));
// Bare tag name as the id (vimwiki parity), so `[[Page#todo]]` resolves. assert!(html.contains("<span class=\"tag\" id=\"tag-todo\">todo</span>"));
assert!(html.contains("<span class=\"tag\" id=\"todo\">todo</span>")); assert!(html.contains("<span class=\"tag\" id=\"tag-done\">done</span>"));
assert!(html.contains("<span class=\"tag\" id=\"done\">done</span>"));
} }
#[test] #[test]
+1 -1
View File
@@ -17,5 +17,5 @@ name = "nuwiki-ls"
path = "src/main.rs" path = "src/main.rs"
[dependencies] [dependencies]
nuwiki-lsp = { path = "../nuwiki-lsp", version = "0.4.2" } nuwiki-lsp = { path = "../nuwiki-lsp", version = "0.1.0" }
tokio = { version = "1", features = ["macros", "rt-multi-thread", "io-std"] } tokio = { version = "1", features = ["macros", "rt-multi-thread", "io-std"] }
+1 -1
View File
@@ -13,7 +13,7 @@ homepage.workspace = true
workspace = true workspace = true
[dependencies] [dependencies]
nuwiki-core = { path = "../nuwiki-core", version = "0.4.2" } nuwiki-core = { path = "../nuwiki-core", version = "0.1.0" }
tower-lsp = "0.20" tower-lsp = "0.20"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "io-std", "sync", "fs"] } tokio = { version = "1", features = ["macros", "rt-multi-thread", "io-std", "sync", "fs"] }
dashmap = "6" dashmap = "6"
+26 -183
View File
@@ -41,7 +41,6 @@ pub const COMMANDS: &[&str] = &[
"nuwiki.heading.removeLevel", "nuwiki.heading.removeLevel",
"nuwiki.toc.generate", "nuwiki.toc.generate",
"nuwiki.links.generate", "nuwiki.links.generate",
"nuwiki.links.generateForPath",
"nuwiki.workspace.checkLinks", "nuwiki.workspace.checkLinks",
"nuwiki.workspace.findOrphans", "nuwiki.workspace.findOrphans",
"nuwiki.diary.openToday", "nuwiki.diary.openToday",
@@ -118,9 +117,6 @@ pub(crate) async fn execute(
"nuwiki.links.generate" => { "nuwiki.links.generate" => {
links_generate(backend, args).map(|o| o.map(CommandOutcome::Edit)) 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" => { "nuwiki.workspace.checkLinks" => {
workspace_check_links(backend, args).map(|o| o.map(CommandOutcome::Value)) workspace_check_links(backend, args).map(|o| o.map(CommandOutcome::Value))
} }
@@ -308,24 +304,6 @@ fn parse_uri_arg(args: Vec<Value>) -> Result<UriArg, String> {
serde_json::from_value(raw).map_err(|e| format!("invalid args: {e}")) 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)] #[derive(Deserialize, Default)]
struct OptionalUriArg { struct OptionalUriArg {
#[serde(default)] #[serde(default)]
@@ -345,13 +323,13 @@ fn parse_optional_uri_arg(args: Vec<Value>) -> Result<OptionalUriArg, String> {
} }
fn toc_generate(backend: &Backend, args: Vec<Value>) -> Result<Option<WorkspaceEdit>, String> { fn toc_generate(backend: &Backend, args: Vec<Value>) -> Result<Option<WorkspaceEdit>, String> {
let (uri, cursor_line) = parse_uri_line_arg(args)?; let p = parse_uri_arg(args)?;
let doc = match backend.documents.get(&uri) { let doc = match backend.documents.get(&p.uri) {
Some(d) => d, Some(d) => d,
None => return Ok(None), None => return Ok(None),
}; };
let utf8 = backend.use_utf8.load(Ordering::Relaxed); let utf8 = backend.use_utf8.load(Ordering::Relaxed);
let wiki = backend.wiki_for_uri(&uri); let wiki = backend.wiki_for_uri(&p.uri);
let (header, level) = wiki let (header, level) = wiki
.as_ref() .as_ref()
.map(|w| (w.config.toc_header.clone(), w.config.toc_header_level)) .map(|w| (w.config.toc_header.clone(), w.config.toc_header_level))
@@ -364,73 +342,27 @@ fn toc_generate(backend: &Backend, args: Vec<Value>) -> Result<Option<WorkspaceE
Ok(ops::toc_edit( Ok(ops::toc_edit(
&doc.text, &doc.text,
&doc.ast, &doc.ast,
&uri, &p.uri,
utf8, utf8,
&header, &header,
level, level,
margin, margin,
link_format, link_format,
cursor_line,
)) ))
} }
fn links_generate(backend: &Backend, args: Vec<Value>) -> Result<Option<WorkspaceEdit>, String> { fn links_generate(backend: &Backend, args: Vec<Value>) -> Result<Option<WorkspaceEdit>, String> {
let (uri, cursor_line) = parse_uri_line_arg(args)?; let p = parse_uri_arg(args)?;
links_generate_scoped(backend, &uri, cursor_line, None) let doc = match backend.documents.get(&p.uri) {
}
/// `: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, Some(d) => d,
None => return Ok(None), None => return Ok(None),
}; };
let utf8 = backend.use_utf8.load(Ordering::Relaxed); let utf8 = backend.use_utf8.load(Ordering::Relaxed);
let wiki = match backend.wiki_for_uri(uri) { let wiki = match backend.wiki_for_uri(&p.uri) {
Some(w) => w, Some(w) => w,
None => return Ok(None), None => return Ok(None),
}; };
let current_page = crate::index::page_name_from_uri(uri, Some(&wiki.config.root)); let current_page = crate::index::page_name_from_uri(&p.uri, Some(&wiki.config.root));
let (pages, captions) = { let (pages, captions) = {
let idx = wiki let idx = wiki
.index .index
@@ -440,20 +372,12 @@ fn links_generate_scoped(
.config .config
.generated_links_caption .generated_links_caption
.then(|| ops::page_captions(&idx)); .then(|| ops::page_captions(&idx));
let names: Vec<String> = match scope { (idx.page_names(), captions)
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( Ok(ops::links_edit(
&doc.text, &doc.text,
&doc.ast, &doc.ast,
uri, &p.uri,
&current_page, &current_page,
&pages, &pages,
utf8, utf8,
@@ -461,7 +385,6 @@ fn links_generate_scoped(
wiki.config.links_header_level, wiki.config.links_header_level,
wiki.config.list_margin.max(0) as usize, wiki.config.list_margin.max(0) as usize,
captions.as_ref(), captions.as_ref(),
cursor_line,
)) ))
} }
@@ -698,9 +621,6 @@ fn tags_generate_links(
uri: Url, uri: Url,
#[serde(default)] #[serde(default)]
tag: Option<String>, tag: Option<String>,
/// 0-based cursor line; a fresh tags section is inserted here.
#[serde(default)]
line: Option<u32>,
} }
let raw = args let raw = args
.into_iter() .into_iter()
@@ -733,7 +653,6 @@ fn tags_generate_links(
&wiki.config.tags_header, &wiki.config.tags_header,
wiki.config.tags_header_level, wiki.config.tags_header_level,
wiki.config.list_margin.max(0) as usize, wiki.config.list_margin.max(0) as usize,
parsed.line,
)) ))
} }
@@ -1440,32 +1359,6 @@ 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 ===== // ===== Pure operations =====
pub mod ops { pub mod ops {
@@ -2167,11 +2060,6 @@ pub mod ops {
/// Produce a `WorkspaceEdit` that replaces or inserts the TOC for the /// Produce a `WorkspaceEdit` that replaces or inserts the TOC for the
/// given document. Returns `None` if the document has no headings at /// given document. Returns `None` if the document has no headings at
/// all (other than possibly a pre-existing TOC heading). /// 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)] #[allow(clippy::too_many_arguments)]
pub fn toc_edit( pub fn toc_edit(
text: &str, text: &str,
@@ -2182,7 +2070,6 @@ pub mod ops {
level: u8, level: u8,
margin: usize, margin: usize,
link_format: u8, link_format: u8,
insert_line: Option<u32>,
) -> Option<WorkspaceEdit> { ) -> Option<WorkspaceEdit> {
let items = collect_toc_items(ast, heading); let items = collect_toc_items(ast, heading);
if items.is_empty() { if items.is_empty() {
@@ -2200,13 +2087,17 @@ pub mod ops {
b.edit(uri.clone(), text_edit_replace(span, new_text, text, utf8)); b.edit(uri.clone(), text_edit_replace(span, new_text, text, utf8));
} }
None => { None => {
// Fresh TOC: at the cursor line, else the top of the file. let with_sep = format!("{new_text}\n");
let top = LspPosition { b.edit(
uri.clone(),
text_edit_insert(
LspPosition {
line: 0, line: 0,
character: 0, character: 0,
}; },
let (pos, block) = section_insert(text, insert_line, top, &new_text); with_sep,
b.edit(uri.clone(), text_edit_insert(pos, block)); ),
);
} }
} }
Some(b.build()) Some(b.build())
@@ -2228,19 +2119,7 @@ pub mod ops {
link_format: u8, link_format: u8,
) -> Option<WorkspaceEdit> { ) -> Option<WorkspaceEdit> {
find_section_range(ast, heading)?; find_section_range(ast, heading)?;
// Rebuild-on-save only ever replaces an existing TOC in place, so the toc_edit(text, ast, uri, utf8, heading, level, margin, link_format)
// insert line is irrelevant.
toc_edit(
text,
ast,
uri,
utf8,
heading,
level,
margin,
link_format,
None,
)
} }
/// Produce a `WorkspaceEdit` that replaces or inserts the /// Produce a `WorkspaceEdit` that replaces or inserts the
@@ -2257,7 +2136,6 @@ pub mod ops {
level: u8, level: u8,
margin: usize, margin: usize,
captions: Option<&std::collections::BTreeMap<String, String>>, captions: Option<&std::collections::BTreeMap<String, String>>,
insert_line: Option<u32>,
) -> Option<WorkspaceEdit> { ) -> Option<WorkspaceEdit> {
if all_pages.is_empty() { if all_pages.is_empty() {
return None; return None;
@@ -2277,9 +2155,9 @@ pub mod ops {
b.edit(uri.clone(), text_edit_replace(span, new_text, text, utf8)); b.edit(uri.clone(), text_edit_replace(span, new_text, text, utf8));
} }
None => { None => {
// Fresh section: at the cursor line, else the end of the file. let with_sep = format!("{new_text}\n");
let (pos, block) = section_insert(text, insert_line, eof_position(text), &new_text); let insert_pos = eof_position(text);
b.edit(uri.clone(), text_edit_insert(pos, block)); b.edit(uri.clone(), text_edit_insert(insert_pos, with_sep));
} }
} }
Some(b.build()) Some(b.build())
@@ -2302,7 +2180,6 @@ pub mod ops {
captions: Option<&std::collections::BTreeMap<String, String>>, captions: Option<&std::collections::BTreeMap<String, String>>,
) -> Option<WorkspaceEdit> { ) -> Option<WorkspaceEdit> {
find_section_range(ast, heading)?; find_section_range(ast, heading)?;
// Rebuild-on-save only ever replaces an existing section in place.
links_edit( links_edit(
text, text,
ast, ast,
@@ -2314,7 +2191,6 @@ pub mod ops {
level, level,
margin, margin,
captions, captions,
None,
) )
} }
@@ -3537,7 +3413,6 @@ pub mod ops {
header: &str, header: &str,
level: u8, level: u8,
margin: usize, margin: usize,
insert_line: Option<u32>,
) -> Option<WorkspaceEdit> { ) -> Option<WorkspaceEdit> {
let body = build_tag_links_text(pages_by_tag, tag, header, level, margin)?; let body = build_tag_links_text(pages_by_tag, tag, header, level, margin)?;
let heading = tag_section_heading(tag, header); let heading = tag_section_heading(tag, header);
@@ -3548,9 +3423,9 @@ pub mod ops {
b.edit(uri.clone(), text_edit_replace(span, body, text, utf8)); b.edit(uri.clone(), text_edit_replace(span, body, text, utf8));
} }
None => { None => {
// Fresh section: at the cursor line, else the end of the file. let with_sep = format!("{body}\n");
let (pos, block) = section_insert(text, insert_line, eof_position(text), &body); let insert_pos = eof_position(text);
b.edit(uri.clone(), text_edit_insert(pos, block)); b.edit(uri.clone(), text_edit_insert(insert_pos, with_sep));
} }
} }
Some(b.build()) Some(b.build())
@@ -3571,7 +3446,6 @@ pub mod ops {
margin: usize, margin: usize,
) -> Option<WorkspaceEdit> { ) -> Option<WorkspaceEdit> {
find_section_range(ast, header)?; find_section_range(ast, header)?;
// Rebuild-on-save only ever replaces an existing section in place.
tag_links_edit( tag_links_edit(
text, text,
ast, ast,
@@ -3582,7 +3456,6 @@ pub mod ops {
header, header,
level, level,
margin, margin,
None,
) )
} }
@@ -3998,33 +3871,3 @@ pub mod export_ops {
out 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/*"));
}
}
+16 -40
View File
@@ -176,10 +176,6 @@ pub struct HtmlConfig {
/// vimwiki `html_header_numbering_sym`: symbol appended after the /// vimwiki `html_header_numbering_sym`: symbol appended after the
/// section number (e.g. `.` → `1.2. Heading`). Empty by default. /// section number (e.g. `.` → `1.2. Heading`). Empty by default.
pub html_header_numbering_sym: String, 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 /// vimwiki `rss_name`: filename of the generated RSS feed (default
/// `rss.xml`), relative to `html_path`. /// `rss.xml`), relative to `html_path`.
pub rss_name: String, pub rss_name: String,
@@ -228,7 +224,6 @@ impl Default for HtmlConfig {
base_url: String::new(), base_url: String::new(),
html_header_numbering: 0, html_header_numbering: 0,
html_header_numbering_sym: String::new(), html_header_numbering_sym: String::new(),
toc_header: default_toc_header(),
rss_name: "rss.xml".into(), rss_name: "rss.xml".into(),
rss_max_items: 10, rss_max_items: 10,
valid_html_tags: default_valid_html_tags(), valid_html_tags: default_valid_html_tags(),
@@ -639,11 +634,14 @@ impl Config {
let wikis = if let Some(list) = raw.wikis { let wikis = if let Some(list) = raw.wikis {
list.into_iter().map(WikiConfig::from).collect() list.into_iter().map(WikiConfig::from).collect()
} else if let Some(wc) = params } else if let Some(root) = raw.wiki_root.as_deref().and_then(expand_path) {
.initialization_options let mut wc = WikiConfig::from_root(root);
.as_ref() if let Some(ext) = raw.file_extension {
.and_then(single_wiki_from_value) wc.file_extension = ext;
{ }
if let Some(s) = raw.syntax {
wc.syntax = s;
}
vec![wc] vec![wc]
} else if let Some(folder) = first_workspace_folder(params) { } else if let Some(folder) = first_workspace_folder(params) {
vec![WikiConfig::from_root(folder)] vec![WikiConfig::from_root(folder)]
@@ -684,7 +682,14 @@ impl Config {
}; };
if let Some(list) = raw.wikis { if let Some(list) = raw.wikis {
self.wikis = list.into_iter().map(WikiConfig::from).collect(); self.wikis = list.into_iter().map(WikiConfig::from).collect();
} else if let Some(wc) = single_wiki_from_value(inner) { } 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;
}
self.wikis = vec![wc]; self.wikis = vec![wc];
} }
// Only touch diagnostic settings when the payload carries them, so a // Only touch diagnostic settings when the payload carries them, so a
@@ -949,9 +954,6 @@ impl From<RawWiki> for WikiConfig {
if let Some(s) = r.html_header_numbering_sym { if let Some(s) = r.html_header_numbering_sym {
html.html_header_numbering_sym = s; 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 { if let Some(s) = r.rss_name {
html.rss_name = s; html.rss_name = s;
} }
@@ -1032,32 +1034,6 @@ 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> { fn expand_path(s: &str) -> Option<PathBuf> {
let trimmed = s.trim(); let trimmed = s.trim();
if trimmed.is_empty() { if trimmed.is_empty() {
+15 -212
View File
@@ -227,32 +227,17 @@ pub fn render_page_html(
} }
let mut r = HtmlRenderer::new(); 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 // Strip the wiki extension from page links before the default resolver
// turns them into `.html` URLs, so `[[todo.wiki]]` → `todo.html` rather // turns them into `.html` URLs, so `[[todo.wiki]]` → `todo.html` rather
// than `todo.wiki.html`. Only wiki/interwiki targets are touched; // than `todo.wiki.html`. Only wiki/interwiki targets are touched;
// file:/local: paths keep their literal extension. // file:/local: paths keep their literal extension.
let ext = wiki_extension let ext = ext.to_string();
.filter(|e| !e.trim_start_matches('.').is_empty())
.map(|e| e.to_string());
r = r.with_link_resolver(move |target| { 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(); let mut t = target.clone();
if let Some(ext) = &ext {
if matches!(t.kind, LinkKind::Wiki | LinkKind::Interwiki) { if matches!(t.kind, LinkKind::Wiki | LinkKind::Interwiki) {
if let Some(p) = t.path.take() { if let Some(p) = t.path.take() {
t.path = t.path = Some(crate::index::strip_wiki_extension(&p, Some(&ext)).to_string());
Some(crate::index::strip_wiki_extension(&p, Some(ext)).to_string());
}
} }
} }
nuwiki_core::render::html::default_link_resolver(&t) nuwiki_core::render::html::default_link_resolver(&t)
@@ -274,7 +259,6 @@ pub fn render_page_html(
cfg.html_header_numbering_sym.clone(), cfg.html_header_numbering_sym.clone(),
); );
} }
r = r.with_toc_header(&cfg.toc_header);
if !cfg.valid_html_tags.is_empty() { if !cfg.valid_html_tags.is_empty() {
r = r.with_valid_html_tags(cfg.valid_html_tags.clone()); r = r.with_valid_html_tags(cfg.valid_html_tags.clone());
} }
@@ -344,7 +328,7 @@ fn escape_html(s: &str) -> String {
/// Minimal glob match for `exclude_files`. Handles `*`, `**`, `?` and /// Minimal glob match for `exclude_files`. Handles `*`, `**`, `?` and
/// literal characters. No character classes — those aren't needed for /// literal characters. No character classes — those aren't needed for
/// "exclude foo/*.tmp" style patterns. /// "exclude foo/*.tmp" style patterns.
pub(crate) fn glob_match(pattern: &str, text: &str) -> bool { fn glob_match(pattern: &str, text: &str) -> bool {
glob_impl(pattern.as_bytes(), text.as_bytes()) glob_impl(pattern.as_bytes(), text.as_bytes())
} }
@@ -397,195 +381,14 @@ pub fn css_path(cfg: &HtmlConfig) -> PathBuf {
cfg.html_path.join(&cfg.css_name) cfg.html_path.join(&cfg.css_name)
} }
/// Default CSS written by `nuwiki.export.*` commands when no CSS file exists /// Minimal default CSS. Used by `nuwiki.export.*` commands when no CSS
/// yet at [`css_path`]. This is vimwiki's stock `style.css` verbatim (MIT /// file exists yet at [`css_path`]. Deliberately tiny — users are
/// licensed) so exports look identical to upstream out of the box and the /// expected to replace it with their own.
/// `.header` / `.tag` / `.toc` / `done*` classes our HTML emits are styled. pub const DEFAULT_CSS: &str =
pub const DEFAULT_CSS: &str = r#"body { "body{font-family:sans-serif;max-width:46em;margin:2em auto;padding:0 1em;line-height:1.55}\
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;; h1,h2,h3,h4,h5,h6{font-family:serif;line-height:1.2}\
margin: 2em 4em 2em 4em; pre{background:#f4f4f4;padding:.5em;overflow:auto}\
font-size: 120%; code{background:#f4f4f4;padding:.1em .25em;border-radius:3px}\
line-height: 130%; table{border-collapse:collapse}\
} table td,table th{border:1px solid #ccc;padding:.25em .5em}\
ul.toc{padding-left:1.5em}\n";
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;
}
"#;
+8 -5
View File
@@ -106,11 +106,14 @@ fn find_in_inlines(inlines: &[InlineNode], line: u32, col: u32) -> Option<&Inlin
InlineNode::Superscript(s) => find_in_inlines(&s.children, line, col), InlineNode::Superscript(s) => find_in_inlines(&s.children, line, col),
InlineNode::Subscript(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::Color(c) => find_in_inlines(&c.children, line, col),
// Links are an atomic unit for navigation: a cursor anywhere in InlineNode::WikiLink(w) => w
// `[[target|description]]` (or `[desc](url)`) should resolve the .description
// link itself. Don't descend into the description — returning its .as_ref()
// inner Text node leaves goto-definition/references/hover with .and_then(|d| find_in_inlines(d, line, col)),
// nothing to act on ("No locations found" when on the title). InlineNode::ExternalLink(e) => e
.description
.as_ref()
.and_then(|d| find_in_inlines(d, line, col)),
_ => None, _ => None,
}; };
return Some(deeper.unwrap_or(n)); return Some(deeper.unwrap_or(n));
+2 -4
View File
@@ -307,7 +307,7 @@ fn nuwiki_toc_generates_nested_contents() {
let doc = parse(src); let doc = parse(src);
let uri = Url::from_file_path("/wiki/Page.wiki").unwrap(); let uri = Url::from_file_path("/wiki/Page.wiki").unwrap();
let edit = let edit =
ops::toc_edit(src, &doc, &uri, true, "Contents", 1, 0, 0, None).expect("toc edit produced"); ops::toc_edit(src, &doc, &uri, true, "Contents", 1, 0, 0).expect("toc edit produced");
assert!(edit.changes.is_some() || edit.document_changes.is_some()); assert!(edit.changes.is_some() || edit.document_changes.is_some());
let toc = ops::build_toc_text( let toc = ops::build_toc_text(
@@ -348,7 +348,6 @@ fn nuwiki_generate_links_lists_all_pages_excluding_current() {
"Generated Links", "Generated Links",
1, 1,
0, 0,
None,
None None
) )
.is_some()); .is_some());
@@ -431,8 +430,7 @@ fn nuwiki_generate_tag_links_builds_section() {
true, true,
"Generated Tags", "Generated Tags",
1, 1,
0, 0
None
) )
.is_some()); .is_some());
} }
@@ -32,10 +32,6 @@ fn commands_list_advertises_link_helpers() {
"nuwiki.link.pasteWikilink", "nuwiki.link.pasteWikilink",
"nuwiki.link.pasteUrl", "nuwiki.link.pasteUrl",
"nuwiki.link.catUrl", "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}"); assert!(names.contains(&name), "missing: {name}");
} }
+2 -47
View File
@@ -255,7 +255,6 @@ fn tag_links_edit_inserts_when_section_missing() {
"Generated Tags", "Generated Tags",
1, 1,
0, 0,
None,
) )
.expect("got an edit"); .expect("got an edit");
let te = &edit.changes.unwrap()[&uri][0]; let te = &edit.changes.unwrap()[&uri][0];
@@ -265,37 +264,6 @@ fn tag_links_edit_inserts_when_section_missing() {
assert!(te.new_text.contains("[[Alpha]]")); 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] #[test]
fn tag_links_edit_replaces_existing_section() { fn tag_links_edit_replaces_existing_section() {
let src = "= Tag: release =\n- [[Stale]]\n\n= Other =\n"; let src = "= Tag: release =\n- [[Stale]]\n\n= Other =\n";
@@ -313,7 +281,6 @@ fn tag_links_edit_replaces_existing_section() {
"Generated Tags", "Generated Tags",
1, 1,
0, 0,
None,
) )
.expect("edit"); .expect("edit");
let te = &edit.changes.unwrap()[&uri][0]; let te = &edit.changes.unwrap()[&uri][0];
@@ -329,18 +296,7 @@ fn tag_links_edit_full_index_replaces_existing_tags_section() {
let uri = Url::parse("file:///tmp/p.wiki").unwrap(); let uri = Url::parse("file:///tmp/p.wiki").unwrap();
let mut snap = BTreeMap::new(); let mut snap = BTreeMap::new();
snap.insert("alpha".to_string(), vec!["P".into()]); snap.insert("alpha".to_string(), vec!["P".into()]);
let edit = ops::tag_links_edit( let edit = ops::tag_links_edit(src, &ast, &uri, None, &snap, true, "Generated Tags", 1, 0)
src,
&ast,
&uri,
None,
&snap,
true,
"Generated Tags",
1,
0,
None,
)
.expect("edit"); .expect("edit");
let te = &edit.changes.unwrap()[&uri][0]; let te = &edit.changes.unwrap()[&uri][0];
assert!(te.new_text.contains("== alpha ==")); assert!(te.new_text.contains("== alpha =="));
@@ -396,8 +352,7 @@ fn tag_links_edit_returns_none_for_unknown_tag() {
true, true,
"Generated Tags", "Generated Tags",
1, 1,
0, 0
None
) )
.is_none()); .is_none());
} }
+9 -58
View File
@@ -385,27 +385,19 @@ fn render_page_html_numbers_headers_when_enabled() {
) )
.unwrap(); .unwrap();
assert!( assert!(
html.contains( html.contains("<h1 id=\"Intro\">1. Intro</h1>"),
"<div id=\"Intro\"><h1 id=\"Intro\" class=\"header\"><a href=\"#Intro\">1. Intro</a></h1></div>"
),
"got: {html}" "got: {html}"
); );
assert!( assert!(
html.contains( html.contains("<h2 id=\"Background\">1.1. Background</h2>"),
"<div id=\"Intro-Background\"><h2 id=\"Background\" class=\"header\"><a href=\"#Intro-Background\">1.1. Background</a></h2></div>"
),
"got: {html}" "got: {html}"
); );
assert!( assert!(
html.contains( html.contains("<h2 id=\"Methods\">1.2. Methods</h2>"),
"<div id=\"Intro-Methods\"><h2 id=\"Methods\" class=\"header\"><a href=\"#Intro-Methods\">1.2. Methods</a></h2></div>"
),
"got: {html}" "got: {html}"
); );
assert!( assert!(
html.contains( html.contains("<h1 id=\"Results\">2. Results</h1>"),
"<div id=\"Results\"><h1 id=\"Results\" class=\"header\"><a href=\"#Results\">2. Results</a></h1></div>"
),
"got: {html}" "got: {html}"
); );
} }
@@ -427,45 +419,14 @@ fn render_page_html_numbering_skips_headers_above_start_level() {
) )
.unwrap(); .unwrap();
assert!( assert!(
html.contains( html.contains("<h1 id=\"Title\">Title</h1>"),
"<div id=\"Title\"><h1 id=\"Title\" class=\"header\"><a href=\"#Title\">Title</a></h1></div>"
),
"h1 unnumbered, got: {html}" "h1 unnumbered, got: {html}"
); );
assert!( assert!(
html.contains( html.contains("<h2 id=\"Section\">1 Section</h2>"),
"<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}" "got: {html}"
); );
assert!(html.contains("<h3 id=\"Sub\">1.1 Sub</h3>"), "got: {html}");
} }
#[test] #[test]
@@ -482,18 +443,8 @@ fn render_page_html_no_header_numbering_by_default() {
HashMap::new(), HashMap::new(),
) )
.unwrap(); .unwrap();
assert!( assert!(html.contains("<h1 id=\"Intro\">Intro</h1>"), "got: {html}");
html.contains( assert!(html.contains("<h2 id=\"Sub\">Sub</h2>"), "got: {html}");
"<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] #[test]
@@ -152,28 +152,6 @@ fn v1_0_single_wiki_desugars_to_one_entry() {
assert_eq!(cfg.wikis[0].syntax, "vimwiki"); 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] #[test]
fn explicit_wikis_list_overrides_legacy_shape() { fn explicit_wikis_list_overrides_legacy_shape() {
let cfg = config_from_json(json!({ let cfg = config_from_json(json!({
@@ -674,20 +652,3 @@ fn legacy_single_wiki_shape_picks_up_defaults() {
assert_eq!(cfg.wikis[0].index, "index"); assert_eq!(cfg.wikis[0].index, "index");
assert_eq!(cfg.wikis[0].diary_frequency, "daily"); 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);
}
+3 -73
View File
@@ -484,8 +484,7 @@ fn toc_edit_inserts_at_top_when_no_existing_toc() {
let src = "= One =\n== Two ==\n"; let src = "= One =\n== Two ==\n";
let ast = parse(src); let ast = parse(src);
let uri = Url::parse("file:///tmp/page.wiki").unwrap(); let uri = Url::parse("file:///tmp/page.wiki").unwrap();
let edit = let edit = ops::toc_edit(src, &ast, &uri, true, "Contents", 1, 0, 0).expect("got an 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 changes = edit.changes.expect("changes map");
let edits = &changes[&uri]; let edits = &changes[&uri];
assert_eq!(edits.len(), 1); assert_eq!(edits.len(), 1);
@@ -498,47 +497,12 @@ fn toc_edit_inserts_at_top_when_no_existing_toc() {
assert!(te.new_text.contains("[[#One|One]]")); 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] #[test]
fn toc_edit_replaces_existing_toc() { fn toc_edit_replaces_existing_toc() {
let src = "= Contents =\n- [[#stale|Stale]]\n\n= Real =\n"; let src = "= Contents =\n- [[#stale|Stale]]\n\n= Real =\n";
let ast = parse(src); let ast = parse(src);
let uri = Url::parse("file:///tmp/page.wiki").unwrap(); let uri = Url::parse("file:///tmp/page.wiki").unwrap();
let edit = let edit = ops::toc_edit(src, &ast, &uri, true, "Contents", 1, 0, 0).expect("got an 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 changes = edit.changes.expect("changes map");
let edits = &changes[&uri]; let edits = &changes[&uri];
let te = &edits[0]; let te = &edits[0];
@@ -553,7 +517,7 @@ fn toc_edit_returns_none_for_empty_doc() {
let src = ""; let src = "";
let ast = parse(src); let ast = parse(src);
let uri = Url::parse("file:///tmp/empty.wiki").unwrap(); let uri = Url::parse("file:///tmp/empty.wiki").unwrap();
assert!(ops::toc_edit(src, &ast, &uri, true, "Contents", 1, 0, 0, None).is_none()); assert!(ops::toc_edit(src, &ast, &uri, true, "Contents", 1, 0, 0).is_none());
} }
#[test] #[test]
@@ -597,7 +561,6 @@ fn links_edit_inserts_when_section_absent() {
1, 1,
0, 0,
None, None,
None,
) )
.expect("edit"); .expect("edit");
let te = &edit.changes.unwrap()[&uri][0]; let te = &edit.changes.unwrap()[&uri][0];
@@ -606,37 +569,6 @@ fn links_edit_inserts_when_section_absent() {
assert!(!te.new_text.contains("[[Home]]")); 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] #[test]
fn links_edit_replaces_when_section_present() { fn links_edit_replaces_when_section_present() {
let src = "= Generated Links =\n- [[Stale]]\n\n= Real =\n"; let src = "= Generated Links =\n- [[Stale]]\n\n= Real =\n";
@@ -654,7 +586,6 @@ fn links_edit_replaces_when_section_present() {
1, 1,
0, 0,
None, None,
None,
) )
.expect("edit"); .expect("edit");
let te = &edit.changes.unwrap()[&uri][0]; let te = &edit.changes.unwrap()[&uri][0];
@@ -678,7 +609,6 @@ fn links_edit_returns_none_for_empty_page_list() {
"Generated Links", "Generated Links",
1, 1,
0, 0,
None,
None None
) )
.is_none()); .is_none());
-12
View File
@@ -201,18 +201,6 @@ fn find_inline_at_returns_wikilink_under_cursor() {
assert!(matches!(node, InlineNode::WikiLink(_))); 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] #[test]
fn find_inline_at_returns_none_for_plain_text() { fn find_inline_at_returns_none_for_plain_text() {
let src = "just text\n"; let src = "just text\n";
-6
View File
@@ -64,12 +64,6 @@ vim.g.nuwiki_diary_rel_path = 'diary'
require('nuwiki').setup({ require('nuwiki').setup({
wiki_root = '$WIKI_DIR', 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 -- Surface log lines (the LSP logs via window/logMessage). Without this
+31 -33
View File
@@ -24,7 +24,6 @@
# NUWIKI_DEV_NO_SEED=1 ./development/start-vim-coc.sh # use WIKI_DIR as-is (no scratch seeding) # 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_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_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 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 # the running LSP. `cargo build --release` is incremental, so this is a fast
@@ -73,43 +72,42 @@ ensure_coc() {
write_coc_settings() { write_coc_settings() {
mkdir -p "$DEV_DIR" mkdir -p "$DEV_DIR"
# No `languageserver.nuwiki` block here on purpose: this dogfoods nuwiki's # Mirror what the vim-lsp client sends (autoload/nuwiki/lsp.vim s:settings()):
# *programmatic* coc registration (autoload/nuwiki/lsp.vim → coc#config). The # both `initializationOptions` (sent on initialize) and `settings.nuwiki`
# plugin registers the server itself from g:nuwiki_* (set in the vimrc), # (served via workspace/configuration). Without these the server falls back
# exactly the "no hand-written coc-settings.json" flow real users get. Only # to its own default wiki_root (~/vimwiki) instead of $WIKI_DIR, so it can't
# the jump-command preference (the <CR>-opens-a-tab reproducer) lives here. # resolve links (e.g. Notes shows as broken) or create-on-follow correctly.
cat > "$COC_SETTINGS" <<EOF cat > "$COC_SETTINGS" <<EOF
{ {
"coc.preferences.jumpCommand": "$COC_JUMP" "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" }
}
}
}
}
} }
EOF EOF
} }
write_vimrc() { write_vimrc() {
mkdir -p "$DEV_DIR" 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 cat > "$VIMRC" <<EOF
" start-vim-coc.sh: generated minimal config for nuwiki development (coc.nvim). " start-vim-coc.sh: generated minimal config for nuwiki development (coc.nvim).
" Regenerated on every launch; edit start-vim-coc.sh, not this file. " Regenerated on every launch; edit start-vim-coc.sh, not this file.
@@ -133,11 +131,11 @@ let g:coc_config_home = '${DEV_DIR}'
let g:coc_data_home = '${COC_DATA}' let g:coc_data_home = '${COC_DATA}'
let g:nuwiki_binary_path = '${REPO_ROOT}/bin/nuwiki-ls' 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_syntax = 'vimwiki'
let g:nuwiki_diary_rel_path = 'diary'
let g:nuwiki_log_level = 'info' 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 filetype plugin indent on
syntax enable syntax enable
-6
View File
@@ -87,12 +87,6 @@ let g:nuwiki_wiki_root = '${WIKI_DIR}'
let g:nuwiki_file_extension = '.wiki' let g:nuwiki_file_extension = '.wiki'
let g:nuwiki_syntax = 'vimwiki' let g:nuwiki_syntax = 'vimwiki'
let g:nuwiki_log_level = 'info' 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 filetype plugin indent on
syntax enable syntax enable
-18
View File
@@ -89,24 +89,6 @@ call s:record(winnr('$') == s:wins_before - 1, 'action.window_count_decremented'
call s:record(&filetype ==# 'vimwiki', 'action.sets_filetype', call s:record(&filetype ==# 'vimwiki', 'action.sets_filetype',
\ 'filetype=' . &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 ===== " ===== Wrap up =====
call add(s:results, '') call add(s:results, '')
@@ -1,71 +0,0 @@
#!/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
@@ -1,42 +0,0 @@
" 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!
@@ -1,87 +0,0 @@
-- 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!')
@@ -1,54 +0,0 @@
#!/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
@@ -1,59 +0,0 @@
#!/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
@@ -1,86 +0,0 @@
" 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)
+4 -10
View File
@@ -525,15 +525,11 @@ Page generation ~
*:NuwikiTOC* *:NuwikiTOC*
:NuwikiTOC :NuwikiTOC
Generate or refresh the table of contents on the current page. A new TOC Generate or refresh the table of contents on the current page.
is inserted at the cursor line; an existing one is refreshed in place.
*:NuwikiGenerateLinks* *:NuwikiGenerateLinks*
:NuwikiGenerateLinks [path] :NuwikiGenerateLinks
Insert a flat list of every page in the wiki. With a `path` argument, the Insert a flat list of every page in the wiki under the cursor.
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* *:NuwikiCheckLinks*
:[range]NuwikiCheckLinks :[range]NuwikiCheckLinks
@@ -559,9 +555,7 @@ Tags ~
*:NuwikiGenerateTagLinks* *:NuwikiGenerateTagLinks*
:NuwikiGenerateTagLinks [tag] :NuwikiGenerateTagLinks [tag]
Insert a section linking every page that has `<tag>`. With no Insert a section linking every page that has `<tag>`. With no
argument, generates a section per tag found in the workspace. A new argument, generates a section per tag found in the workspace.
section is inserted at the cursor line; an existing one is refreshed in
place.
*:NuwikiRebuildTags* *:NuwikiRebuildTags*
:NuwikiRebuildTags[!] :NuwikiRebuildTags[!]
-10
View File
@@ -128,16 +128,6 @@ correct as-is; the deferral is a deliberate risk/reward call.
## Notes ## 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` - **Third-party plugin compatibility.** A shim at `autoload/vimwiki/vars.vim`
exposes the subset of `vimwiki#vars#get_wikilocal` that plugins like exposes the subset of `vimwiki#vars#get_wikilocal` that plugins like
vimwiki-sync and vim-zettel call (`path`, `ext`/`extension`, `syntax`, vimwiki-sync and vim-zettel call (`path`, `ext`/`extension`, `syntax`,
+6 -11
View File
@@ -513,20 +513,16 @@ end
-- ===== Generation + workspace ===== -- ===== Generation + workspace =====
M.toc_generate = function() M.toc_generate = function() exec('nuwiki.toc.generate', uri_args()) end
-- 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 -- `:VimwikiGenerateLinks [rel_path]` — optional path scopes the links to a
-- subtree via the server's generateForPath; no arg = every page. -- subtree via the server's generateForPath; no arg = every page.
M.links_generate = function(path) 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 if path and path ~= '' then
exec('nuwiki.links.generateForPath', { { uri = buf_uri(), path = path, line = line } }) local args = uri_args()[1]
args.path = path
exec('nuwiki.links.generateForPath', { args })
else else
exec('nuwiki.links.generate', { { uri = buf_uri(), line = line } }) exec('nuwiki.links.generate', uri_args())
end end
end end
@@ -606,8 +602,7 @@ function M.tags_search(query)
end end
function M.tags_generate_links(tag) function M.tags_generate_links(tag)
-- Cursor line (0-based) so a fresh section is inserted there. local args = { uri = buf_uri() }
local args = { uri = buf_uri(), line = vim.api.nvim_win_get_cursor(0)[1] - 1 }
if tag and tag ~= '' then if tag and tag ~= '' then
args.tag = tag args.tag = tag
end end
+10 -126
View File
@@ -35,19 +35,10 @@ M.defaults = {
-- custom_wiki2html, custom_wiki2html_args, base_url, -- custom_wiki2html, custom_wiki2html_args, base_url,
-- toc_header, toc_header_level, links_header, links_header_level, -- toc_header, toc_header_level, links_header, links_header_level,
-- tags_header, tags_header_level, -- tags_header, tags_header_level,
-- html_header_numbering, html_header_numbering_sym,
-- listsyms, listsym_rejected, listsyms_propagate, list_margin, -- listsyms, listsym_rejected, listsyms_propagate, list_margin,
-- links_space_char -- links_space_char
wikis = nil, 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 -- Per-buffer glue. Each subgroup mirrors vimwiki's
-- `g:vimwiki_key_mappings` shape and can be flipped off -- `g:vimwiki_key_mappings` shape and can be flipped off
-- independently to suppress that group of keymaps. -- independently to suppress that group of keymaps.
@@ -103,126 +94,19 @@ M.defaults = {
M.options = vim.deepcopy(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) function M.apply(user)
M.user = user or {}
M.options = vim.tbl_deep_extend('force', M.defaults, user or {}) M.options = vim.tbl_deep_extend('force', M.defaults, user or {})
end 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. -- Resolve the config for wiki #n (0-based) without touching the LSP.
-- Returns { name, root, ext, idx, diary_rel, diary_idx, space }. -- Priority: setup() opts.wikis → g:nuwiki_wikis (VimL) → scalar opts/g: vars.
-- Returns { name, root, ext, idx, diary_rel, diary_idx }.
function M.wiki_cfg(n) function M.wiki_cfg(n)
local wikis = M.wikis() local opts = M.options
local w = wikis[(n or 0) + 1] local wikis = opts.wikis or vim.g.nuwiki_wikis or nil
local root = (w and w.root) or M.options.wiki_root or vim.g.nuwiki_wiki_root or '~/vimwiki' local w = wikis and wikis[(n or 0) + 1] or nil
local ext = (w and w.file_extension) or M.options.file_extension local root = (w and w.root) or opts.wiki_root or vim.g.nuwiki_wiki_root or '~/vimwiki'
or vim.g.nuwiki_file_extension or '.wiki' local ext = (w and w.file_extension) or opts.file_extension or vim.g.nuwiki_file_extension or '.wiki'
if not ext:match('^%.') then ext = '.' .. ext end if not ext:match('^%.') then ext = '.' .. ext end
return { return {
name = (w and w.name) or vim.fn.fnamemodify(vim.fn.expand(root), ':t'), name = (w and w.name) or vim.fn.fnamemodify(vim.fn.expand(root), ':t'),
@@ -231,7 +115,7 @@ function M.wiki_cfg(n)
idx = (w and w.index) or 'index', 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_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', diary_idx = (w and w.diary_index) or 'diary',
space = (w and w.links_space_char) or M.options.links_space_char space = (w and w.links_space_char) or opts.links_space_char
or vim.g.nuwiki_links_space_char or ' ', or vim.g.nuwiki_links_space_char or ' ',
} }
end end
@@ -239,9 +123,9 @@ end
-- The full list of configured wikis as wiki_cfg dicts. Falls back to a single -- 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. -- entry built from the scalar wiki_root config when no list is configured.
function M.wiki_list() function M.wiki_list()
local wikis = M.wikis() local wikis = M.options.wikis or vim.g.nuwiki_wikis or nil
local list = {} local list = {}
if #wikis > 0 then if type(wikis) == 'table' and #wikis > 0 then
for i = 1, #wikis do for i = 1, #wikis do
list[i] = M.wiki_cfg(i - 1) list[i] = M.wiki_cfg(i - 1)
end end
-11
View File
@@ -93,17 +93,6 @@ end
function M.setup(opts) function M.setup(opts)
config.apply(opts or {}) 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 -- Neovim 0.7+ resolves filetypes via `vim.filetype.match` before any
-- `ftdetect/*.vim` autocmd fires, and its bundled rule for `*.wiki` -- `ftdetect/*.vim` autocmd fires, and its bundled rule for `*.wiki`
-- returns `mediawiki`. Register an authoritative rule here so this -- returns `mediawiki`. Register an authoritative rule here so this
+1 -4
View File
@@ -74,11 +74,8 @@ local function download_release(dest)
if vim.fn.executable('curl') ~= 1 or vim.fn.executable('tar') ~= 1 then if vim.fn.executable('curl') ~= 1 or vim.fn.executable('tar') ~= 1 then
return false return false
end 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( local url = string.format(
'https://code.gfran.co/gffranco/nuwiki/releases/download/latest/nuwiki-ls-%s.tar.gz', 'https://code.gfran.co/gffranco/nuwiki/releases/latest/download/nuwiki-ls-%s.tar.gz',
target target
) )
local archive = vim.fn.tempname() .. '.tar.gz' local archive = vim.fn.tempname() .. '.tar.gz'
+5 -14
View File
@@ -14,22 +14,13 @@ local function command()
return { install.expected_path() } return { install.expected_path() }
end end
-- Build the server config payload. The wiki list comes from the shared -- Merge g:nuwiki_wikis (VimL config) into opts.wikis when the user has not
-- resolver (config.wikis) — native setup() wikis, g:nuwiki_wikis, or an -- passed wikis through setup(). This lets users who configure via a .vim
-- upstream g:vimwiki_list, with global scalar defaults already folded — so the -- file avoid duplicating the list in their Lua setup() call.
-- payload and the buffer-side commands agree on which wikis exist.
local function resolved_opts() local function resolved_opts()
local opts = config.options local opts = config.options
local wikis = config.wikis() if not opts.wikis and vim.g.nuwiki_wikis then
if #wikis > 0 then opts = vim.tbl_extend('force', opts, { wikis = vim.g.nuwiki_wikis })
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 end
return opts return opts
end end
+1 -6
View File
@@ -15,7 +15,7 @@ let g:loaded_nuwiki = 1
" Plugin version, surfaced by :VimwikiShowVersion / :NuwikiShowVersion. " Plugin version, surfaced by :VimwikiShowVersion / :NuwikiShowVersion.
" Set before the Neovim early-return below so both clients see the global. " Set before the Neovim early-return below so both clients see the global.
if !exists('g:nuwiki_version') if !exists('g:nuwiki_version')
let g:nuwiki_version = '0.4.2' let g:nuwiki_version = '0.1.0'
endif endif
" Resolve the plugin root NOW, while this script is being sourced and " Resolve the plugin root NOW, while this script is being sourced and
@@ -37,11 +37,6 @@ endif
augroup nuwiki_calendar_detect augroup nuwiki_calendar_detect
autocmd! autocmd!
autocmd FileType vimwiki call s:nuwiki_setup_calendar() 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 augroup END
function! s:nuwiki_setup_calendar() abort function! s:nuwiki_setup_calendar() abort
if !get(g:, 'nuwiki_use_calendar', 1) || get(g:, 'nuwiki_no_calendar', 0) if !get(g:, 'nuwiki_use_calendar', 1) || get(g:, 'nuwiki_no_calendar', 0)
+1 -14
View File
@@ -3,14 +3,6 @@
" "
" Invoked by Dein / vim-plug build hooks: " Invoked by Dein / vim-plug build hooks:
" vim -e -s -c "source scripts/download_bin.vim" -c "q" " 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:plugin_dir = fnamemodify(resolve(expand('<sfile>:p')), ':h:h')
let s:bin_dir = s:plugin_dir . '/bin' let s:bin_dir = s:plugin_dir . '/bin'
@@ -64,10 +56,7 @@ function! s:download_release() abort
if !executable('curl') || !executable('tar') if !executable('curl') || !executable('tar')
return 0 return 0
endif endif
" Gitea serves the most recent release under the `latest` tag segment let l:url = 'https://code.gfran.co/gffranco/nuwiki/releases/latest/download/nuwiki-ls-'
" (/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' \ . l:target . '.tar.gz'
let l:archive = tempname() . '.tar.gz' let l:archive = tempname() . '.tar.gz'
call system('curl -fsSL -o ' . shellescape(l:archive) . ' ' . shellescape(l:url)) call system('curl -fsSL -o ' . shellescape(l:archive) . ' ' . shellescape(l:url))
@@ -97,5 +86,3 @@ else
echohl ErrorMsg | echom 'nuwiki: install failed' | echohl None echohl ErrorMsg | echom 'nuwiki: install failed' | echohl None
endif endif
endif endif
let &cpo = s:cpo_save
-57
View File
@@ -1,57 +0,0 @@
#!/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"