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