Compare commits
119 Commits
calendar-support
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5375e30bda | |||
| 51e857f3c6 | |||
| 4c187fee3c | |||
| cd173f000c | |||
| 4bee216c05 | |||
| 82444f5013 | |||
| a9208db2e4 | |||
| ac1db1af1d | |||
| 101cb1cea9 | |||
| 3859d3b0e7 | |||
| 1c299f4ae5 | |||
| 4e4f39dbf2 | |||
| 62a3d49649 | |||
| 71b47393af | |||
| cb11889e72 | |||
| 0df72e52ea | |||
| 4a717bcb31 | |||
| f1494dd2fc | |||
| a653903dba | |||
| e0f806d307 | |||
| 5dd02e7b9e | |||
| 8fdfa9e256 | |||
| 59494e9a86 | |||
| e1d734a434 | |||
| fcce5621a0 | |||
| 3e8c534859 | |||
| 0d1a73a3ba | |||
| e6f400b370 | |||
| 96d53dddf3 | |||
| c144cbbb55 | |||
| 3c1ed48a2f | |||
| 34a0607e7a | |||
| daee0f1902 | |||
| f0c51fbfcc | |||
| 94cb58064d | |||
| 9d6d28a1b6 | |||
| 346e8b0de6 | |||
| ac14cdd838 | |||
| a4643bdacb | |||
| 9d89e01ed8 | |||
| fc0d74bfbe | |||
| f3d8af5f23 | |||
| bd729d513d | |||
| 5a936e4f96 | |||
| 32cf4508de | |||
| 7e757c6a7f | |||
| a5a07768d6 | |||
| 08ebdc6f9b | |||
| b77f4b1ced | |||
| c9d75aeb1f | |||
| cd4bfffef9 | |||
| 0de2bd9d68 | |||
| e530b3d623 | |||
| a2ca49a64c | |||
| 5090ab1be0 | |||
| 68f10971f7 | |||
| ddb9d0b916 | |||
| 255e24d1e0 | |||
| 38fed6dd9e | |||
| 30cccd88bd | |||
| 3b92b11948 | |||
| e319b4a935 | |||
| e6e3f70dd2 | |||
| 1246c99c3c | |||
| f5420bd1da | |||
| a63d3dd7cf | |||
| 5cc47b0a42 | |||
| 4a19f180f2 | |||
| 5d1ac09245 | |||
| dcb5d89c0c | |||
| 87d14310fc | |||
| b2f2fc88bd | |||
| 03005d0931 | |||
| 724712121d | |||
| 305324c6e9 | |||
| 6deba47106 | |||
| a11b742fc1 | |||
| 3865b8bf0e | |||
| c161f9d21a | |||
| 9441fe918c | |||
| 079e7246ac | |||
| d8d1e9e39e | |||
| 79b502fa5c | |||
| 88114a65a4 | |||
| 2da2168d88 | |||
| 874bdd0c02 | |||
| f0c07f0c28 | |||
| f616915581 | |||
| 773bbdb6a6 | |||
| 23f5a74086 | |||
| 2b3bc48b75 | |||
| 3b3d8ca782 | |||
| efea225240 | |||
| acaaf107b4 | |||
| 4710ba03c8 | |||
| fe116088bb | |||
| a7f7e8e61f | |||
| 724c12ac16 | |||
| 8dc4050b76 | |||
| 3e82f59f12 | |||
| 2a8e2b9ea9 | |||
| feedd30c4d | |||
| 23aec3e6c7 | |||
| 09b5a2bd46 | |||
| 3fd04b0efa | |||
| 545367c3f6 | |||
| 2d3db458fb | |||
| 624bccbe50 | |||
| db9c1c5c11 | |||
| 212b300fb4 | |||
| b8586537f8 | |||
| 28d5caf581 | |||
| d8565fbe67 | |||
| 086455e19b | |||
| 9de8543385 | |||
| 15ba774a28 | |||
| 2facb40265 | |||
| 279dacff7a | |||
| 66bfd61a63 |
+35
-73
@@ -2,105 +2,67 @@ name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUST_BACKTRACE: short
|
||||
RUSTFLAGS: -D warnings
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
fmt:
|
||||
name: cargo fmt --check
|
||||
editor-tests:
|
||||
name: editor tests
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@1.83
|
||||
with:
|
||||
components: rustfmt
|
||||
- run: cargo fmt --all -- --check
|
||||
|
||||
clippy:
|
||||
name: cargo clippy
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@1.83
|
||||
with:
|
||||
components: clippy
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: ${{ runner.os }}-cargo-clippy-${{ hashFiles('**/Cargo.lock', '**/Cargo.toml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-cargo-clippy-
|
||||
- run: cargo clippy --workspace --all-targets -- -D warnings
|
||||
|
||||
test:
|
||||
name: cargo test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@1.83
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: ${{ runner.os }}-cargo-test-${{ hashFiles('**/Cargo.lock', '**/Cargo.toml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-cargo-test-
|
||||
- run: cargo test --workspace --all-targets
|
||||
|
||||
keymaps:
|
||||
name: editor keymaps
|
||||
runs-on: ubuntu-latest
|
||||
# Serialise behind the cargo jobs so we never compete with them
|
||||
# for runners, and inherit their warm cargo cache instead of
|
||||
# forcing a release build of our own.
|
||||
needs: test
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@1.83
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: ${{ runner.os }}-cargo-test-${{ hashFiles('**/Cargo.lock', '**/Cargo.toml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-cargo-test-
|
||||
- name: install Neovim 0.11 + Vim
|
||||
- name: install Neovim + Vim
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Apt's neovim is too old (Ubuntu LTS ships 0.6/0.7), missing
|
||||
# `vim.lsp.get_clients` and rejecting `@group.modifier`
|
||||
# highlight names. Pull the official static tarball instead.
|
||||
NVIM_VER=v0.11.3
|
||||
# Test the minimum supported Neovim version (0.11.0) so we
|
||||
# catch API regressions at the bottom of the supported range.
|
||||
NVIM_VER=v0.11.0
|
||||
curl -fsSL -o /tmp/nvim.tar.gz \
|
||||
"https://github.com/neovim/neovim/releases/download/${NVIM_VER}/nvim-linux-x86_64.tar.gz"
|
||||
sudo tar -xzf /tmp/nvim.tar.gz -C /opt
|
||||
sudo ln -sf /opt/nvim-linux-x86_64/bin/nvim /usr/local/bin/nvim
|
||||
nvim --version | head -1
|
||||
# Vim from apt is fine — only the pure-VimL keymaps run there.
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y --no-install-recommends vim
|
||||
vim --version | head -1
|
||||
|
||||
- name: install Neovim (latest)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl -fsSL -o /tmp/nvim.tar.gz \
|
||||
"https://github.com/neovim/neovim/releases/latest/download/nvim-linux-x86_64.tar.gz"
|
||||
sudo tar -xzf /tmp/nvim.tar.gz -C /opt
|
||||
sudo ln -sf /opt/nvim-linux-x86_64/bin/nvim /usr/local/bin/nvim
|
||||
|
||||
- name: run Neovim keymap harness
|
||||
run: ./development/tests/test-keymaps.sh
|
||||
|
||||
- name: run Vim keymap harness
|
||||
run: ./development/tests/test-keymaps-vim.sh
|
||||
|
||||
- name: run Neovim config parity harness
|
||||
run: ./development/tests/test-config.sh
|
||||
|
||||
- name: run Vim config parity harness
|
||||
run: ./development/tests/test-config-vim.sh
|
||||
|
||||
- name: run Neovim calendar integration harness
|
||||
run: ./development/tests/test-calendar.sh
|
||||
|
||||
- name: run Vim calendar integration harness
|
||||
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
|
||||
+115
-120
@@ -1,156 +1,151 @@
|
||||
name: Release
|
||||
|
||||
# Cut a plugin release from the Actions UI: "Run workflow" → enter the
|
||||
# version (e.g. 0.5.1). This repo is the Vim/Neovim plugin only and ships no
|
||||
# binary (the nuwiki-ls server lives in nuwiki-rs and is downloaded at install
|
||||
# time), so a release is just: stamp g:nuwiki_version, commit, tag vX.Y.Z, and
|
||||
# publish a Gitea release entry. No build / cross-compile.
|
||||
#
|
||||
# Dispatch-only on purpose: the job creates the tag itself, so a push-tag
|
||||
# trigger would double-fire.
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Release version, no leading v (e.g. 0.5.1)'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUST_BACKTRACE: short
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: build ${{ matrix.target }}
|
||||
release:
|
||||
name: tag + release ${{ inputs.version }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- target: x86_64-unknown-linux-gnu
|
||||
apt: ""
|
||||
rustflags: ""
|
||||
- target: aarch64-unknown-linux-gnu
|
||||
# gcc-aarch64-linux-gnu ships the cross compiler/binutils
|
||||
# but no target libc; libc6-dev-arm64-cross adds Scrt1.o,
|
||||
# crti.o, and friends needed at link time.
|
||||
apt: "gcc-aarch64-linux-gnu libc6-dev-arm64-cross"
|
||||
rustflags: ""
|
||||
- target: x86_64-unknown-linux-musl
|
||||
apt: "musl-tools"
|
||||
rustflags: ""
|
||||
- target: aarch64-unknown-linux-musl
|
||||
apt: ""
|
||||
rustflags: "-C linker=rust-lld -C link-self-contained=yes"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: dtolnay/rust-toolchain@1.83
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
fetch-depth: 0
|
||||
# Push the release commit + tag back with a write-capable token.
|
||||
token: ${{ secrets.RELEASE_TOKEN }}
|
||||
|
||||
- name: Install cross toolchain
|
||||
if: matrix.apt != ''
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y --no-install-recommends ${{ matrix.apt }}
|
||||
|
||||
- name: Cache cargo state
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: ${{ runner.os }}-cargo-release-${{ matrix.target }}-${{ hashFiles('**/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-cargo-release-${{ matrix.target }}-
|
||||
|
||||
- name: Build nuwiki-ls
|
||||
- name: Validate version + stamp
|
||||
id: stamp
|
||||
env:
|
||||
# Per-target linker overrides. Cargo ignores the entries that
|
||||
# don't match the current target, so setting all of them here
|
||||
# keeps the matrix declarative.
|
||||
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc
|
||||
CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER: musl-gcc
|
||||
RUSTFLAGS: ${{ matrix.rustflags }}
|
||||
run: cargo build --release --target ${{ matrix.target }} -p nuwiki-ls
|
||||
|
||||
- name: Package archive
|
||||
id: package
|
||||
VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version="${GITHUB_REF_NAME#v}"
|
||||
archive="nuwiki-ls-${version}-${{ matrix.target }}.tar.gz"
|
||||
tar -czf "$archive" -C "target/${{ matrix.target }}/release" nuwiki-ls
|
||||
echo "archive=$archive" >> "$GITHUB_OUTPUT"
|
||||
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.5.1)"
|
||||
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
|
||||
# g:nuwiki_version in plugin/nuwiki.vim is the plugin's only version
|
||||
# location (surfaced by :NuwikiShowVersion / health).
|
||||
sed -i -E "s/(let g:nuwiki_version = ')[^']*(')/\1$ver\2/" plugin/nuwiki.vim
|
||||
if ! grep -q "let g:nuwiki_version = '$ver'" plugin/nuwiki.vim; then
|
||||
echo "::error::failed to stamp g:nuwiki_version in plugin/nuwiki.vim"
|
||||
exit 1
|
||||
fi
|
||||
echo "version=$ver" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Upload build artifact
|
||||
# Gitea Actions doesn't support upload-artifact v4 (uses a
|
||||
# GitHub-only backend API); pin to v3.
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: nuwiki-ls-${{ matrix.target }}
|
||||
path: ${{ steps.package.outputs.archive }}
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
- name: Sanity-check the plugin sources cleanly
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y --no-install-recommends vim
|
||||
# plugin/nuwiki.vim must source without error (silent Ex mode exits
|
||||
# non-zero on any error), and expose the stamped version.
|
||||
vim -e -s -u NONE -N \
|
||||
-c 'set runtimepath+=.' \
|
||||
-c 'runtime plugin/nuwiki.vim' \
|
||||
-c "if get(g:, 'nuwiki_version', '') !=# '${{ steps.stamp.outputs.version }}' | cquit 1 | endif" \
|
||||
-c 'qall!'
|
||||
|
||||
release:
|
||||
name: gitea release
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Download all build artifacts
|
||||
# Paired with upload-artifact@v3 above. v3 has no merge-multiple
|
||||
# option and nests each artifact under its own subdirectory.
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
path: ./artifacts
|
||||
- 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 plugin/nuwiki.vim
|
||||
git commit -m "chore(release): $VER"
|
||||
git tag -a "$TAG" -m "nuwiki plugin $VER"
|
||||
git push origin "HEAD:${GITHUB_REF_NAME}"
|
||||
git push origin "$TAG"
|
||||
|
||||
- name: Ensure jq + curl
|
||||
run: |
|
||||
if ! command -v jq >/dev/null 2>&1; then
|
||||
apt-get update && apt-get install -y --no-install-recommends jq
|
||||
fi
|
||||
if ! command -v curl >/dev/null 2>&1; then
|
||||
apt-get update && apt-get install -y --no-install-recommends curl
|
||||
fi
|
||||
command -v jq >/dev/null 2>&1 || { apt-get update && apt-get install -y --no-install-recommends jq; }
|
||||
command -v curl >/dev/null 2>&1 || { apt-get update && apt-get install -y --no-install-recommends curl; }
|
||||
|
||||
- name: Create Gitea release + upload assets
|
||||
- name: Generate release notes
|
||||
id: notes
|
||||
env:
|
||||
TAG: ${{ steps.stamp.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 "")
|
||||
if [ -n "$prev_tag" ]; then
|
||||
log=$(git log --oneline "$prev_tag..$TAG")
|
||||
compare="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/compare/${prev_tag}...${TAG}"
|
||||
else
|
||||
log=$(git log --oneline "$TAG")
|
||||
compare="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/commits/tag/${TAG}"
|
||||
fi
|
||||
cat > release-notes.md <<EOF
|
||||
Vim/Neovim plugin release. The \`nuwiki-ls\` server binary is downloaded
|
||||
automatically from [nuwiki-rs](${GITHUB_SERVER_URL}/gffranco/nuwiki-rs)
|
||||
at install time — no binary assets are attached here by design.
|
||||
|
||||
**Full Changelog**: ${compare}
|
||||
|
||||
**Changes**:
|
||||
${log}
|
||||
EOF
|
||||
echo "wrote release notes"
|
||||
|
||||
- name: Create Gitea release
|
||||
env:
|
||||
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
GITEA_SERVER: ${{ github.server_url }}
|
||||
REPO: ${{ github.repository }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
TAG: ${{ steps.stamp.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${RELEASE_TOKEN:-}" ]; then
|
||||
echo "::error::RELEASE_TOKEN secret is not set"
|
||||
exit 1
|
||||
fi
|
||||
echo "Releasing $TAG to $GITEA_SERVER/$REPO"
|
||||
|
||||
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 \
|
||||
# Idempotent: skip if a release for this tag already exists.
|
||||
existing=$(curl --silent -o /dev/null -w "%{http_code}" \
|
||||
-H "Authorization: token $RELEASE_TOKEN" \
|
||||
"$GITEA_SERVER/api/v1/repos/$REPO/releases/tags/$TAG" || true)
|
||||
if [ "$existing" = "200" ]; then
|
||||
echo "release $TAG already exists — nothing to do"
|
||||
exit 0
|
||||
fi
|
||||
notes=$(cat release-notes.md 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}')
|
||||
code=$(curl --silent -o /tmp/rel.json -w "%{http_code}" \
|
||||
-H "Authorization: token $RELEASE_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$payload" \
|
||||
"$GITEA_SERVER/api/v1/repos/$REPO/releases" \
|
||||
| jq -r '.id')
|
||||
|
||||
if [ -z "$release_id" ] || [ "$release_id" = "null" ]; then
|
||||
echo "::error::failed to create release"
|
||||
exit 1
|
||||
"$GITEA_SERVER/api/v1/repos/$REPO/releases")
|
||||
if [ "$code" != "201" ]; then
|
||||
echo "::error::failed to create release (HTTP $code)"; cat /tmp/rel.json; exit 1
|
||||
fi
|
||||
echo "Created release id=$release_id"
|
||||
|
||||
# download-artifact@v3 nests each artifact under its own dir
|
||||
# (artifacts/<artifact-name>/<file>), so recurse.
|
||||
for archive in $(find artifacts -name '*.tar.gz' -type f); do
|
||||
name="$(basename "$archive")"
|
||||
echo "Uploading $name …"
|
||||
curl --fail --silent --show-error \
|
||||
-H "Authorization: token $RELEASE_TOKEN" \
|
||||
-H "Content-Type: application/gzip" \
|
||||
--data-binary "@$archive" \
|
||||
"$GITEA_SERVER/api/v1/repos/$REPO/releases/$release_id/assets?name=$name" \
|
||||
>/dev/null
|
||||
done
|
||||
echo "All assets uploaded."
|
||||
echo "created release $TAG"
|
||||
|
||||
+6
-2
@@ -1,9 +1,10 @@
|
||||
# Rust build artifacts
|
||||
/target
|
||||
**/*.rs.bk
|
||||
|
||||
# Plugin runtime: downloaded LSP binary lives here (see SPEC.md §7.2)
|
||||
# Plugin runtime: downloaded LSP binary lives here (see development/SPEC.md)
|
||||
/bin
|
||||
# nuwiki-rs is cloned here during install fallback, not tracked
|
||||
nuwiki-rs/
|
||||
|
||||
# Editor / OS noise
|
||||
.DS_Store
|
||||
@@ -14,3 +15,6 @@
|
||||
|
||||
# Vim help tags (regenerated by :helptags)
|
||||
doc/tags
|
||||
|
||||
# Claude Code local session artifacts
|
||||
.claude/
|
||||
|
||||
Generated
-835
@@ -1,835 +0,0 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.89"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "auto_impl"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ffdcb70bdbc4d478427380519163274ac86e52916e10f0a8889adf0f96d3fee7"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "1.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-utils"
|
||||
version = "0.8.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
|
||||
|
||||
[[package]]
|
||||
name = "dashmap"
|
||||
version = "5.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"hashbrown",
|
||||
"lock_api",
|
||||
"once_cell",
|
||||
"parking_lot_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dashmap"
|
||||
version = "6.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"crossbeam-utils",
|
||||
"hashbrown",
|
||||
"lock_api",
|
||||
"once_cell",
|
||||
"parking_lot_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "displaydoc"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "form_urlencoded"
|
||||
version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
|
||||
dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"futures-sink",
|
||||
"futures-task",
|
||||
"futures-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-channel"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
|
||||
|
||||
[[package]]
|
||||
name = "futures-io"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
|
||||
|
||||
[[package]]
|
||||
name = "futures-macro"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-sink"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
|
||||
|
||||
[[package]]
|
||||
name = "futures-task"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
|
||||
|
||||
[[package]]
|
||||
name = "futures-util"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"futures-macro",
|
||||
"futures-sink",
|
||||
"futures-task",
|
||||
"memchr",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.14.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||
|
||||
[[package]]
|
||||
name = "httparse"
|
||||
version = "1.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||
|
||||
[[package]]
|
||||
name = "icu_collections"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_locid"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"litemap",
|
||||
"tinystr",
|
||||
"writeable",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_locid_transform"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"icu_locid",
|
||||
"icu_locid_transform_data",
|
||||
"icu_provider",
|
||||
"tinystr",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_locid_transform_data"
|
||||
version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7515e6d781098bf9f7205ab3fc7e9709d34554ae0b21ddbcb5febfa4bc7df11d"
|
||||
|
||||
[[package]]
|
||||
name = "icu_normalizer"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"icu_collections",
|
||||
"icu_normalizer_data",
|
||||
"icu_properties",
|
||||
"icu_provider",
|
||||
"smallvec",
|
||||
"utf16_iter",
|
||||
"utf8_iter",
|
||||
"write16",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_normalizer_data"
|
||||
version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c5e8338228bdc8ab83303f16b797e177953730f601a96c25d10cb3ab0daa0cb7"
|
||||
|
||||
[[package]]
|
||||
name = "icu_properties"
|
||||
version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"icu_collections",
|
||||
"icu_locid_transform",
|
||||
"icu_properties_data",
|
||||
"icu_provider",
|
||||
"tinystr",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_properties_data"
|
||||
version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85fb8799753b75aee8d2a21d7c14d9f38921b54b3dbda10f5a3c7a7b82dba5e2"
|
||||
|
||||
[[package]]
|
||||
name = "icu_provider"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"icu_locid",
|
||||
"icu_provider_macros",
|
||||
"stable_deref_trait",
|
||||
"tinystr",
|
||||
"writeable",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_provider_macros"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
|
||||
dependencies = [
|
||||
"idna_adapter",
|
||||
"smallvec",
|
||||
"utf8_iter",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna_adapter"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "daca1df1c957320b2cf139ac61e7bd64fed304c5040df000a745aa1de3b4ef71"
|
||||
dependencies = [
|
||||
"icu_normalizer",
|
||||
"icu_properties",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.186"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
||||
|
||||
[[package]]
|
||||
name = "litemap"
|
||||
version = "0.7.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23fb14cb19457329c82206317a5663005a4d404783dc74f4252769b0d5f42856"
|
||||
|
||||
[[package]]
|
||||
name = "lock_api"
|
||||
version = "0.4.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
|
||||
dependencies = [
|
||||
"scopeguard",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lsp-types"
|
||||
version = "0.94.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c66bfd44a06ae10647fe3f8214762e9369fd4248df1350924b4ef9e770a85ea1"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||
|
||||
[[package]]
|
||||
name = "nuwiki-core"
|
||||
version = "0.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "nuwiki-ls"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"nuwiki-lsp",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nuwiki-lsp"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"dashmap 6.1.0",
|
||||
"nuwiki-core",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tower-lsp",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot_core"
|
||||
version = "0.9.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"redox_syscall",
|
||||
"smallvec",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "percent-encoding"
|
||||
version = "2.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "pin-project"
|
||||
version = "1.1.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cbf0d9e68100b3a7989b4901972f265cd542e560a3a8a724e1e20322f4d06ce9"
|
||||
dependencies = [
|
||||
"pin-project-internal",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-internal"
|
||||
version = "1.1.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a990e22f43e84855daf260dded30524ef4a9021cc7541c26540500a50b624389"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.5.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scopeguard"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.149"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_repr"
|
||||
version = "0.1.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
||||
|
||||
[[package]]
|
||||
name = "stable_deref_trait"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.117"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "synstructure"
|
||||
version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinystr"
|
||||
version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.52.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
|
||||
dependencies = [
|
||||
"pin-project-lite",
|
||||
"tokio-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-macros"
|
||||
version = "2.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower"
|
||||
version = "0.4.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"pin-project",
|
||||
"pin-project-lite",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower-layer"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
|
||||
|
||||
[[package]]
|
||||
name = "tower-lsp"
|
||||
version = "0.20.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4ba052b54a6627628d9b3c34c176e7eda8359b7da9acd497b9f20998d118508"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"auto_impl",
|
||||
"bytes",
|
||||
"dashmap 5.5.3",
|
||||
"futures",
|
||||
"httparse",
|
||||
"lsp-types",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-lsp-macros",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower-lsp-macros"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "84fd902d4e0b9a4b27f2f440108dc034e1758628a9b702f8ec61ad66355422fa"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tower-service"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
|
||||
|
||||
[[package]]
|
||||
name = "tracing"
|
||||
version = "0.1.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||
dependencies = [
|
||||
"pin-project-lite",
|
||||
"tracing-attributes",
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-attributes"
|
||||
version = "0.1.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-core"
|
||||
version = "0.1.36"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "url"
|
||||
version = "2.5.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
|
||||
dependencies = [
|
||||
"form_urlencoded",
|
||||
"idna",
|
||||
"percent-encoding",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "utf16_iter"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246"
|
||||
|
||||
[[package]]
|
||||
name = "utf8_iter"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "write16"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936"
|
||||
|
||||
[[package]]
|
||||
name = "writeable"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51"
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.7.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "120e6aef9aa629e3d4f52dc8cc43a015c7724194c97dfaf45180d2daf2b77f40"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"stable_deref_trait",
|
||||
"yoke-derive",
|
||||
"zerofrom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yoke-derive"
|
||||
version = "0.7.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerofrom"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df"
|
||||
dependencies = [
|
||||
"zerofrom-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerofrom-derive"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerovec"
|
||||
version = "0.10.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079"
|
||||
dependencies = [
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerovec-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerovec-derive"
|
||||
version = "0.10.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = [
|
||||
"crates/nuwiki-core",
|
||||
"crates/nuwiki-lsp",
|
||||
"crates/nuwiki-ls",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.83"
|
||||
authors = ["Gabriel Fróes Franco <gffranco@gmail.com>"]
|
||||
license = "MIT OR Apache-2.0"
|
||||
repository = "https://code.gfran.co/gffranco/nuwiki"
|
||||
homepage = "https://code.gfran.co/gffranco/nuwiki"
|
||||
|
||||
[workspace.lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
|
||||
[workspace.lints.clippy]
|
||||
all = { level = "warn", priority = -1 }
|
||||
@@ -6,7 +6,16 @@ nuwiki replaces the original [vimwiki](https://github.com/vimwiki/vimwiki)
|
||||
plugin while keeping its file format, keymaps, and `:Vimwiki*` command
|
||||
surface intact. The substantive work happens in a Rust LSP daemon — Vim
|
||||
and Neovim are thin client layers that wire up keystrokes and display
|
||||
results.
|
||||
results. The `nuwiki-ls` binary is distributed separately in the
|
||||
[nuwiki-rs](https://code.gfran.co/gffranco/nuwiki-rs) repository.
|
||||
|
||||
> [!NOTE]
|
||||
> **This repo is the Vim/Neovim plugin (VimL + Lua) only.** The Rust LSP
|
||||
> server — parser, AST, HTML renderer, all `nuwiki-ls` source and tests —
|
||||
> lives in **[nuwiki-rs](https://code.gfran.co/gffranco/nuwiki-rs)**. The
|
||||
> plugin downloads a prebuilt `nuwiki-ls` binary from there automatically,
|
||||
> so installing this plugin needs no Rust toolchain. To work on the server,
|
||||
> head to nuwiki-rs.
|
||||
|
||||
## Features
|
||||
|
||||
@@ -50,11 +59,11 @@ results.
|
||||
|
||||
### Diary
|
||||
|
||||
- Daily diary out of the box (`<Leader>ww`, `<C-Down>` / `<C-Up>`).
|
||||
- Daily diary out of the box (`<Leader>w<Leader>w`, `<C-Down>` / `<C-Up>`).
|
||||
- Configurable cadence: set `diary_frequency = 'weekly'` (or `'monthly'`
|
||||
/ `'yearly'`) per wiki and the commands address `2026-W19.wiki`,
|
||||
`2026-05.wiki`, `2026.wiki` instead. Navigation stays at the same
|
||||
cadence as the entry under the cursor.
|
||||
/ `'yearly'`) per wiki and the commands address
|
||||
`2026-W19.wiki` / `2026-05.wiki` / `2026.wiki` instead. Navigation
|
||||
stays at the same cadence as the entry under the cursor.
|
||||
- Diary index page generation (`:VimwikiDiaryGenerateLinks`).
|
||||
|
||||
### Other
|
||||
@@ -72,9 +81,12 @@ results.
|
||||
|
||||
The plugin ships a `nuwiki-ls` binary that the editor talks to over LSP
|
||||
stdio. The `install()` helper downloads a pre-built release for your
|
||||
platform, falling back to `cargo build --release` if no asset matches.
|
||||
You can run it any time with `:NuwikiInstall` (works in both Vim and
|
||||
Neovim) instead of relying on a plugin-manager build hook.
|
||||
platform from the [nuwiki-rs](https://code.gfran.co/gffranco/nuwiki-rs)
|
||||
repository. If the download fails (or `curl`/`tar` are unavailable), it
|
||||
falls back to cloning nuwiki-rs and building with `cargo` — which requires
|
||||
a Rust toolchain (1.83+ stable). You can run install manually any time with
|
||||
`:NuwikiInstall` (works in both Vim and Neovim) instead of relying on a
|
||||
plugin-manager build hook.
|
||||
|
||||
### Requirements
|
||||
|
||||
@@ -82,12 +94,14 @@ Neovim) instead of relying on a plugin-manager build hook.
|
||||
Older releases fall back to a `vim.lsp.start` autocmd but aren't
|
||||
officially supported.
|
||||
- **Vim 9+** — needs a third-party LSP client:
|
||||
[`vim-lsp`](https://github.com/prabirshrestha/vim-lsp) (recommended,
|
||||
auto-registered) or [`coc.nvim`](https://github.com/neoclide/coc.nvim)
|
||||
(one-time `coc-settings.json` entry — see [Vim LSP client
|
||||
[`vim-lsp`](https://github.com/prabirshrestha/vim-lsp) or
|
||||
[`coc.nvim`](https://github.com/neoclide/coc.nvim) — both
|
||||
auto-registered, no manual config (see [Vim LSP client
|
||||
setup](#vim-lsp-client-setup)).
|
||||
- **Rust toolchain (1.83+ stable)** — only needed when building the
|
||||
binary from source; pre-built release downloads skip this.
|
||||
- **curl + tar** — for downloading pre-built release binaries.
|
||||
- **Rust toolchain (1.83+ stable)** — only needed when building
|
||||
the binary from source as a fallback (when no pre-built asset matches
|
||||
your platform or curl/tar are unavailable).
|
||||
|
||||
### lazy.nvim
|
||||
|
||||
@@ -126,10 +140,13 @@ call dein#add('gffranco/nuwiki', {
|
||||
|
||||
```sh
|
||||
git clone https://code.gfran.co/gffranco/nuwiki ~/.vim/pack/gffranco/start/nuwiki
|
||||
cd ~/.vim/pack/gffranco/start/nuwiki && cargo build --release -p nuwiki-ls
|
||||
mkdir -p bin && ln -s ../target/release/nuwiki-ls bin/nuwiki-ls
|
||||
cd ~/.vim/pack/gffranco/start/nuwiki && vim -e -s -c "source scripts/download_bin.vim" -c "q"
|
||||
```
|
||||
|
||||
If the download fails, `download_bin.vim` will clone
|
||||
[nuwiki-rs](https://code.gfran.co/gffranco/nuwiki-rs) and build from source
|
||||
(requires `cargo`).
|
||||
|
||||
### Vim LSP client setup
|
||||
|
||||
Neovim 0.11+ registers the server through its built-in LSP client
|
||||
@@ -145,10 +162,18 @@ auto-enables it. Your wiki settings (`g:nuwiki_wiki_root`,
|
||||
`g:nuwiki_wikis`, …) are forwarded as the server's initialization
|
||||
options.
|
||||
|
||||
**coc.nvim** — [`coc.nvim`](https://github.com/neoclide/coc.nvim) reads
|
||||
its server list from `coc-settings.json`, which the plugin can't edit
|
||||
on your behalf, so add the entry once yourself (open it with
|
||||
`:CocConfig`):
|
||||
**coc.nvim** — also zero configuration. nuwiki registers itself with
|
||||
[`coc.nvim`](https://github.com/neoclide/coc.nvim) programmatically
|
||||
(`coc#config('languageserver.nuwiki', …)`) when the first `.wiki` buffer
|
||||
loads, forwarding the same wiki settings (`g:nuwiki_wiki_root`,
|
||||
`g:nuwiki_wikis`, your `g:vimwiki_*` config, the global shorthand, …) as
|
||||
the server's initialization options. **No `coc-settings.json`
|
||||
`languageserver` entry needed** — change your wiki config and reload; the
|
||||
new settings are picked up.
|
||||
|
||||
To manage the entry yourself instead, set `let g:nuwiki_no_coc_register = 1`
|
||||
and add it to `coc-settings.json` manually (nuwiki then just prints the
|
||||
snippet on first load):
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -188,9 +213,13 @@ aliases.
|
||||
./development/start-vim.sh # same, for plain Vim (clones vim-lsp on first run)
|
||||
```
|
||||
|
||||
Both scripts build the LSP binary, seed a scratch wiki, and launch the
|
||||
Both scripts download the LSP binary, seed a scratch wiki, and launch the
|
||||
editor against an isolated state dir under `$XDG_CACHE_HOME/nuwiki-dev`.
|
||||
|
||||
Alternatively, set `g:nuwiki_binary_path` to point at a `nuwiki-ls`
|
||||
binary built from the [nuwiki-rs](https://code.gfran.co/gffranco/nuwiki-rs)
|
||||
repository.
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
@@ -219,6 +248,8 @@ require('nuwiki').setup({
|
||||
diary_rel_path = 'diary',
|
||||
diary_index = 'diary',
|
||||
diary_frequency = 'daily', -- 'daily' | 'weekly' | 'monthly' | 'yearly'
|
||||
diary_weekly_style = 'iso', -- 'iso' (YYYY-Www) | 'date' (week-start YYYY-MM-DD, vimwiki)
|
||||
diary_start_week_day = 'monday', -- week start for diary_weekly_style='date'
|
||||
diary_sort = 'desc', -- 'desc' | 'asc'
|
||||
diary_header = 'Diary',
|
||||
-- HTML export
|
||||
@@ -227,11 +258,21 @@ require('nuwiki').setup({
|
||||
template_default = 'default',
|
||||
template_ext = '.tpl',
|
||||
css_name = 'style.css',
|
||||
custom_wiki2html = '', -- external converter; empty = built-in renderer
|
||||
custom_wiki2html_args = '', -- extra args appended to the converter call
|
||||
base_url = '', -- public URL prefix for RSS item links
|
||||
auto_export = false, -- export on save
|
||||
auto_toc = false, -- auto-refresh TOC on save
|
||||
auto_generate_links = false, -- regen Generated Links on save
|
||||
auto_generate_tags = false, -- regen Generated Tags index on save
|
||||
auto_diary_index = false, -- regen diary index on diary-entry save
|
||||
toc_header = 'Contents', -- :VimwikiTOC caption (+ toc_header_level = 1)
|
||||
links_header = 'Generated Links',
|
||||
tags_header = 'Generated Tags',
|
||||
exclude_files = {}, -- glob patterns
|
||||
-- Checkbox progression characters: " .oOX" by default.
|
||||
listsyms = ' .oOX',
|
||||
listsym_rejected = '-', -- glyph for a cancelled checkbox [-]
|
||||
listsyms_propagate = true,
|
||||
},
|
||||
},
|
||||
@@ -239,20 +280,25 @@ require('nuwiki').setup({
|
||||
-- Keymap groups. Flip subgroups off independently. Defaults all on.
|
||||
mappings = {
|
||||
enabled = true,
|
||||
wiki_prefix = true, -- <Leader>w*
|
||||
links = true, -- <CR>, <S-CR>, <Tab>, <BS>, +, …
|
||||
lists = true, -- <C-Space>, gln/glp/glx, gl/gL, o/O, …
|
||||
wiki_prefix = true, -- <Leader>w{w,t,s,i}, <Leader>w<Leader>{w,y,t,m,i}
|
||||
links = true, -- <CR>, <S-CR>, <C-CR>, <C-S-CR>, <D-CR>, <M-CR>, <BS>, <Tab>, <S-Tab>, + (n/x), <Leader>w{n,d,r}, <Leader>wc (n/x)
|
||||
lists = true, -- <C-Space>, gnt, gln, glp, glx, glh, gll, gLh, gLl (aliases gLH/gLL), glr, gLr (alias gLR), gl{-,*,#,1,i,I,a,A}, gL{…}, gl, gL, o, O; insert <C-D>, <C-T>, <C-L><C-J/K/M>, <CR>
|
||||
headers = true, -- =, -, ]], [[, ]=, [=, ]u, [u
|
||||
table_editing = true, -- gqq, <A-Left>, <A-Right>, <Tab> in insert mode
|
||||
table_editing = true, -- gqq, gq1, gww, gw1, <A-Left>, <A-Right>; insert <Tab>, <S-Tab>
|
||||
diary = true, -- <C-Down>, <C-Up>
|
||||
html_export = true, -- <Leader>wh, <Leader>whh
|
||||
html_export = true, -- <Leader>wh, <Leader>whh, <Leader>wha
|
||||
text_objects = true, -- ah, ih, aH, iH, al, il, a\, i\, ac, ic
|
||||
mouse = false, -- <2-LeftMouse>, <RightMouse>, … (opt-in)
|
||||
mouse = false, -- <2-LeftMouse>, <S-2-LeftMouse>, <C-2-LeftMouse>, <MiddleMouse>, <RightMouse> (opt-in)
|
||||
},
|
||||
|
||||
-- 'lsp' (server-driven foldingRange, default) | 'expr' (regex) | 'off'.
|
||||
folding = 'lsp',
|
||||
|
||||
-- Mirror Vim's &autowriteall while in a wiki buffer (auto-save on switch).
|
||||
autowriteall = true,
|
||||
-- Re-align the table under the cursor on InsertLeave.
|
||||
table_auto_fmt = true,
|
||||
|
||||
-- Broken-link diagnostic severity: 'off' | 'hint' | 'warn' | 'error'.
|
||||
diagnostic = {
|
||||
link_severity = 'warn',
|
||||
@@ -269,7 +315,10 @@ let g:nuwiki_file_extension = '.wiki'
|
||||
let g:nuwiki_log_level = 'warn'
|
||||
let g:nuwiki_link_severity = 'warn' " 'off'|'hint'|'warn'|'error'
|
||||
let g:nuwiki_no_default_mappings = 0 " set to 1 to skip the keymap layer
|
||||
let g:nuwiki_map_prefix = '<Leader>w' " wiki command prefix
|
||||
let g:nuwiki_no_folding = 0 " set to 1 to skip foldexpr setup
|
||||
let g:nuwiki_autowriteall = 1 " set to 0 to not touch &autowriteall
|
||||
let g:nuwiki_table_auto_fmt = 1 " set to 0 to skip InsertLeave table re-align
|
||||
let g:nuwiki_mouse_mappings = 0 " set to 1 to enable mouse maps
|
||||
" Drop individual keymap subgroups (mirror the Lua `mappings.<group>` toggles):
|
||||
" g:nuwiki_no_{wiki_prefix,links,lists,headers,table_editing,diary,html_export,text_objects}_mappings
|
||||
@@ -285,7 +334,13 @@ let g:nuwiki_mouse_mappings = 0 " set to 1 to enable mouse maps
|
||||
| `file_extension` | string | `'.wiki'` | any extension, leading `.` |
|
||||
| `syntax` | string | `'vimwiki'` | `'vimwiki'` (`'markdown'` planned) |
|
||||
| `log_level` | string | `'warn'` | `'error'` \| `'warn'` \| `'info'` \| `'debug'` |
|
||||
| `map_prefix` | string | `'<Leader>w'` | prefix for the wiki command family (`<prefix>w`, `<prefix>t`, `<prefix><Leader>w`, …); mirrors vimwiki's `g:vimwiki_map_prefix`. Vim users set `g:nuwiki_map_prefix` |
|
||||
| `folding` | string | `'lsp'` | `'lsp'` \| `'expr'` \| `'off'` |
|
||||
| `autowriteall` | bool | `true` | mirror Vim's `'autowriteall'` while in a wiki buffer (vimwiki `autowriteall`); Vim users set `g:nuwiki_autowriteall` |
|
||||
| `table_auto_fmt` | bool | `true` | re-align the table under the cursor on `InsertLeave` (vimwiki `table_auto_fmt`); Vim users set `g:nuwiki_table_auto_fmt` |
|
||||
| `auto_chdir` | bool | `false` | `:lcd` into the owning wiki's root when a wiki buffer becomes current (vimwiki `auto_chdir`); Vim users set `g:nuwiki_auto_chdir` |
|
||||
| `auto_header` | bool | `false` | insert a level-1 header from the filename on new wiki pages (vimwiki `auto_header`); honours `links_space_char`. Vim users set `g:nuwiki_auto_header` |
|
||||
| `use_calendar` | bool | `true` | wire up calendar-vim integration when it's on the runtimepath (`g:calendar_action`/`g:calendar_sign`); set `false` (or `g:nuwiki_no_calendar`) to opt out |
|
||||
| `wikis` | list of tables | `nil` | per-wiki tables (see below); wins over the single-wiki shorthand |
|
||||
| `mappings` | table | all on | keymap subgroups (see below) |
|
||||
| `diagnostic` | table | `{ link_severity = 'warn' }` | `link_severity`: `'off'` \| `'hint'` \| `'warn'` \| `'error'` |
|
||||
@@ -294,21 +349,31 @@ let g:nuwiki_mouse_mappings = 0 " set to 1 to enable mouse maps
|
||||
|
||||
Each is a boolean. All default to `true` except `mouse`.
|
||||
|
||||
| Key | Default | Controls |
|
||||
The `<Leader>w` prefix in the `wiki_prefix`, `links`, and `html_export` rows is the default `map_prefix` and moves with it.
|
||||
|
||||
| Key | Default | Controls (every binding in the group) |
|
||||
|-----|---------|----------|
|
||||
| `enabled` | `true` | master switch for the whole keymap layer |
|
||||
| `wiki_prefix` | `true` | `<Leader>w*` |
|
||||
| `links` | `true` | `<CR>`, `<S-CR>`, `<Tab>`, `<BS>`, `+`, … |
|
||||
| `lists` | `true` | `<C-Space>`, `gln`/`glp`/`glx`, `gl`/`gL`, `o`/`O`, … |
|
||||
| `wiki_prefix` | `true` | `<Leader>ww`, `<Leader>wt`, `<Leader>ws`, `<Leader>wi`, `<Leader>w<Leader>w`, `<Leader>w<Leader>y`, `<Leader>w<Leader>t`, `<Leader>w<Leader>m`, `<Leader>w<Leader>i` |
|
||||
| `links` | `true` | `<CR>`, `<S-CR>`, `<C-CR>`, `<C-S-CR>`, `<D-CR>`, `<M-CR>`, `<BS>`, `<Tab>`, `<S-Tab>`, `+` (normal + visual), `<CR>` (visual: normalize selection), `<Leader>wn`, `<Leader>wd`, `<Leader>wr`, `<Leader>wc` (normal + visual) |
|
||||
| `lists` | `true` | `<C-Space>` (also `<C-@>`/`<Nul>`), `gnt`, `gln`, `glp`, `glx`, `glh`, `gll`, `gLh`, `gLl`, `gLH`, `gLL`, `glr`, `gLr`, `gLR`, `gl-`, `gl*`, `gl#`, `gl1`, `gli`, `glI`, `gla`, `glA`, `gL-`, `gL*`, `gL#`, `gL1`, `gLi`, `gLI`, `gLa`, `gLA`, `gl`, `gL`, `o`, `O`; insert: `<C-D>`, `<C-T>`, `<C-L><C-J>`, `<C-L><C-K>`, `<C-L><C-M>`, `<CR>`, `<S-CR>` (multiline item) |
|
||||
| `headers` | `true` | `=`, `-`, `]]`, `[[`, `]=`, `[=`, `]u`, `[u` |
|
||||
| `table_editing` | `true` | `gqq`, `<A-Left>`, `<A-Right>`, `<Tab>` (insert) |
|
||||
| `table_editing` | `true` | `gqq`, `gq1`, `gww`, `gw1`, `<A-Left>`, `<A-Right>`; insert: `<Tab>`, `<S-Tab>` |
|
||||
| `diary` | `true` | `<C-Down>`, `<C-Up>` |
|
||||
| `html_export` | `true` | `<Leader>wh`, `<Leader>whh` |
|
||||
| `html_export` | `true` | `<Leader>wh`, `<Leader>whh`, `<Leader>wha` |
|
||||
| `text_objects` | `true` | `ah`, `ih`, `aH`, `iH`, `al`, `il`, `a\`, `i\`, `ac`, `ic` |
|
||||
| `mouse` | `false` | `<2-LeftMouse>`, `<RightMouse>`, … (opt-in) |
|
||||
| `mouse` | `false` | `<2-LeftMouse>`, `<S-2-LeftMouse>`, `<C-2-LeftMouse>`, `<MiddleMouse>`, `<RightMouse>` (opt-in) |
|
||||
|
||||
#### 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 |
|
||||
@@ -319,14 +384,31 @@ Each is a boolean. All default to `true` except `mouse`.
|
||||
| `diary_rel_path` | string | `'diary'` | path relative to `root` |
|
||||
| `diary_index` | string | `'diary'` | diary index page stem |
|
||||
| `diary_frequency` | string | `'daily'` | `'daily'` \| `'weekly'` \| `'monthly'` \| `'yearly'` |
|
||||
| `diary_caption_level` | int | `1` | `1`–`6` (level of the index caption) |
|
||||
| `diary_weekly_style` | string | `'iso'` | `'iso'` (`YYYY-Www`) \| `'date'`/`'vimwiki'` (week-start `YYYY-MM-DD`). Weekly only |
|
||||
| `diary_start_week_day` | string | `'monday'` | `'monday'`–`'sunday'`; week start when `diary_weekly_style = 'date'` |
|
||||
| `diary_caption_level` | int | `0` | `0`–`6` (level of the index caption) |
|
||||
| `diary_sort` | string | `'desc'` | `'desc'` \| `'asc'` |
|
||||
| `diary_header` | string | `'Diary'` | any heading text |
|
||||
| `diary_months` | list | English month names | month-number → display name used in the diary index |
|
||||
| `create_link` | bool | `true` | create the target page when following a link to a missing page; `false` makes the follow a no-op |
|
||||
| `dir_link` | string | `''` | index stem opened when following a link to a directory (e.g. `'index'`); empty opens the directory itself |
|
||||
| `bullet_types` | list | `['-', '*', '#']` | unordered-bullet glyphs for this wiki; drives `cycle_bullets` |
|
||||
| `cycle_bullets` | bool | `false` | rotate an unordered item's glyph through `bullet_types` as it is indented/dedented |
|
||||
| `generated_links_caption` | bool | `false` | emit `[[page\|Heading]]` (first heading as caption) from `:VimwikiGenerateLinks` |
|
||||
| `toc_link_format` | int | `0` | `0` = `[[#anchor\|title]]` (with description); `1` = `[[#anchor]]` (anchor only) |
|
||||
| `table_reduce_last_col` | bool | `false` | don't pad the last table column to fill — keep it at its minimum width |
|
||||
| `listsyms` | string | `' .oOX'` | checkbox progression chars |
|
||||
| `listsym_rejected` | string | `'-'` | glyph for a cancelled checkbox (`[-]`); first char used |
|
||||
| `listsyms_propagate` | bool | `true` | `true` \| `false` |
|
||||
| `list_margin` | int | `-1` | `-1` (auto) or `≥ 0` |
|
||||
| `links_space_char` | string | `' '` | char that replaces spaces in synthesised link paths |
|
||||
| `auto_toc` | bool | `false` | `true` \| `false` |
|
||||
| `auto_generate_links` | bool | `false` | regenerate an existing Generated Links section on save |
|
||||
| `auto_generate_tags` | bool | `false` | regenerate an existing Generated Tags index on save |
|
||||
| `auto_diary_index` | bool | `false` | regenerate the diary index when a diary entry is saved |
|
||||
| `toc_header` / `toc_header_level` | string / int | `'Contents'` / `1` | `:VimwikiTOC` caption + heading level |
|
||||
| `links_header` / `links_header_level` | string / int | `'Generated Links'` / `1` | `:VimwikiGenerateLinks` caption + level |
|
||||
| `tags_header` / `tags_header_level` | string / int | `'Generated Tags'` / `1` | `:VimwikiGenerateTagLinks` index caption + level |
|
||||
|
||||
#### HTML export keys (per-wiki)
|
||||
|
||||
@@ -340,8 +422,19 @@ Each is a boolean. All default to `true` except `mouse`.
|
||||
| `css_name` | string | `'style.css'` | stylesheet filename |
|
||||
| `auto_export` | bool | `false` | `true` \| `false` (export on save) |
|
||||
| `html_filename_parameterization` | bool | `false` | `true` \| `false` |
|
||||
| `custom_wiki2html` | string | `''` | external converter command; empty = built-in renderer. Called as `<cmd> <force> <syntax> <ext> <out_dir> <in_file> <css> <tpl_path> <tpl_default> <tpl_ext> <root_path> <args>` (`-` for empty optionals), matching vimwiki |
|
||||
| `custom_wiki2html_args` | string | `''` | extra args appended to the `custom_wiki2html` call |
|
||||
| `base_url` | string | `''` | public URL prefix; when set, diary RSS item links become `<base_url><diary_rel_path>/<date>.html` instead of `file://` |
|
||||
| `exclude_files` | list | `{}` | glob patterns |
|
||||
| `color_dic` | table | `{}` | `{ name = 'color', … }` |
|
||||
| `color_dic` | table | `{}` | `{ name = 'color', … }` colour-tag name → CSS value |
|
||||
| `color_tag_template` | string | `'<span style="__STYLE__">__CONTENT__</span>'` | template for a `color_dic` colour span; `__STYLE__` → `color:<css>`, `__CONTENT__` → inner HTML |
|
||||
| `valid_html_tags` | string | `'b,i,s,u,sub,sup,kbd,br,hr,div,center,strong,em'` | comma-separated inline HTML tags passed through export unescaped (`span` is always allowed) |
|
||||
| `emoji_enable` | bool | `true` | substitute `:alias:` emoji shortcodes with their glyph during export |
|
||||
| `text_ignore_newline` | bool | `true` | a single newline inside a paragraph is a space in HTML; `false` → `<br>` |
|
||||
| `list_ignore_newline` | bool | `true` | a single newline inside a list item is a space in HTML; `false` → `<br>` |
|
||||
| `rss_name` | string | `'rss.xml'` | filename of the generated diary RSS feed, relative to `html_path` |
|
||||
| `rss_max_items` | int | `10` | cap on the number of diary items in the RSS feed |
|
||||
| `user_htmls` | list | `{}` | basenames of HTML files with no wiki source that `:VimwikiAll2HTML` must not prune |
|
||||
|
||||
#### Vim globals (`g:nuwiki_*`)
|
||||
|
||||
@@ -351,19 +444,27 @@ For users configuring without Lua.
|
||||
|--------|---------|---------|
|
||||
| `g:nuwiki_wiki_root` | `'~/vimwiki'` | single-wiki root |
|
||||
| `g:nuwiki_file_extension` | `'.wiki'` | wiki file extension |
|
||||
| `g:nuwiki_syntax` | `'vimwiki'` | wiki syntax |
|
||||
| `g:nuwiki_diary_rel_path` | `'diary'` | diary subdirectory (relative to the wiki root) |
|
||||
| `g:nuwiki_wikis` | _(unset)_ | list of per-wiki dicts for multi-wiki setups; wins over the scalar globals |
|
||||
| `g:nuwiki_log_level` | `'warn'` | `error` \| `warn` \| `info` \| `debug` |
|
||||
| `g:nuwiki_link_severity` | `'warn'` | broken-link severity: `off` \| `hint` \| `warn` \| `error` |
|
||||
| `g:nuwiki_map_prefix` | `'<Leader>w'` | prefix for the wiki command family |
|
||||
| `g:nuwiki_no_default_mappings` | `0` | `1` skips the whole keymap layer |
|
||||
| `g:nuwiki_no_wiki_prefix_mappings` | `0` | `1` skips the `<Leader>w*` group |
|
||||
| `g:nuwiki_no_links_mappings` | `0` | `1` skips the link group (`<CR>`, `+`, …) |
|
||||
| `g:nuwiki_no_lists_mappings` | `0` | `1` skips the list group (`<C-Space>`, `gl*`, `o`/`O`, …) |
|
||||
| `g:nuwiki_no_headers_mappings` | `0` | `1` skips the header group (`=`, `-`, `]]`, …) |
|
||||
| `g:nuwiki_no_table_editing_mappings` | `0` | `1` skips the table group (`gqq`, `<A-Left>`, insert `<Tab>`) |
|
||||
| `g:nuwiki_no_diary_mappings` | `0` | `1` skips the diary nav group (`<C-Down>`, `<C-Up>`) |
|
||||
| `g:nuwiki_no_html_export_mappings` | `0` | `1` skips the HTML export group (`<Leader>wh*`) |
|
||||
| `g:nuwiki_no_text_objects_mappings` | `0` | `1` skips the text-object group (`ah`, `il`, …) |
|
||||
| `g:nuwiki_no_wiki_prefix_mappings` | `0` | `1` skips the `wiki_prefix` group (`<Leader>ww`, `<Leader>wt`, `<Leader>ws`, `<Leader>wi`, `<Leader>w<Leader>{w,y,t,m,i}`) |
|
||||
| `g:nuwiki_no_links_mappings` | `0` | `1` skips the `links` group (`<CR>`, `<S-CR>`, `<C-CR>`, `<C-S-CR>`, `<D-CR>`, `<M-CR>`, `<BS>`, `<Tab>`, `<S-Tab>`, `+`, `<Leader>w{n,d,r,c}`) |
|
||||
| `g:nuwiki_no_lists_mappings` | `0` | `1` skips the `lists` group (`<C-Space>`, `gnt`, `gln`, `glp`, `glx`, `glh`, `gll`, `gLh`, `gLl` (aliases `gLH`/`gLL`), `glr`, `gLr` (alias `gLR`), `gl{-,*,#,1,i,I,a,A}`, `gL{…}`, `gl`, `gL`, `o`, `O`; insert `<C-D>`, `<C-T>`, `<C-L><C-J/K/M>`, `<CR>`) |
|
||||
| `g:nuwiki_no_headers_mappings` | `0` | `1` skips the `headers` group (`=`, `-`, `]]`, `[[`, `]=`, `[=`, `]u`, `[u`) |
|
||||
| `g:nuwiki_no_table_editing_mappings` | `0` | `1` skips the `table_editing` group (`gqq`, `gq1`, `gww`, `gw1`, `<A-Left>`, `<A-Right>`; insert `<Tab>`, `<S-Tab>`) |
|
||||
| `g:nuwiki_no_diary_mappings` | `0` | `1` skips the `diary` nav group (`<C-Down>`, `<C-Up>`) |
|
||||
| `g:nuwiki_no_html_export_mappings` | `0` | `1` skips the `html_export` group (`<Leader>wh`, `<Leader>whh`, `<Leader>wha`) |
|
||||
| `g:nuwiki_no_text_objects_mappings` | `0` | `1` skips the `text_objects` group (`ah`, `ih`, `aH`, `iH`, `al`, `il`, `a\`, `i\`, `ac`, `ic`) |
|
||||
| `g:nuwiki_no_folding` | `0` | `1` skips foldexpr setup |
|
||||
| `g:nuwiki_mouse_mappings` | `0` | `1` enables mouse maps |
|
||||
| `g:nuwiki_binary_path` | _(auto)_ | path to a prebuilt `nuwiki-ls` binary, bypassing the bundled one |
|
||||
| `g:nuwiki_no_calendar` | `0` | `1` opts out of calendar-vim integration |
|
||||
| `g:nuwiki_auto_chdir` | `0` | `1` `:lcd` into the wiki root when a wiki buffer becomes current |
|
||||
| `g:nuwiki_auto_header` | `0` | `1` insert a level-1 header from the filename on new wiki pages |
|
||||
|
||||
---
|
||||
|
||||
@@ -399,7 +500,10 @@ available on both the Neovim and plain-Vim paths.
|
||||
| `:NuwikiNextLink` / `:NuwikiPrevLink` | `:VimwikiNextLink` / `:VimwikiPrevLink` | Jump to the next / previous wikilink |
|
||||
| `:NuwikiBaddLink` | `:VimwikiBaddLink` | Add the link target under the cursor to the buffer list |
|
||||
| `:NuwikiNormalizeLink` | `:VimwikiNormalizeLink` | Wrap the word / visual selection under the cursor as a wikilink |
|
||||
| — | `:VimwikiSearch {pat}` (`:VWS`) | `:lvimgrep` `{pat}` across every page |
|
||||
| `:NuwikiSearch {pat}` | `:VimwikiSearch {pat}` (`:VWS`) | `:lvimgrep` `{pat}` across the current wiki's pages (empty `{pat}` reuses the last search) |
|
||||
| `:NuwikiVar` | `:VimwikiVar [key [val]]` | Get/set nuwiki client config |
|
||||
| `:NuwikiReturn` | `:VimwikiReturn` | Command form of the smart `<CR>` list/table continuation |
|
||||
| `:NuwikiShowVersion` | `:VimwikiShowVersion` | Print the nuwiki version + host editor |
|
||||
|
||||
**Diary**
|
||||
|
||||
@@ -425,10 +529,10 @@ available on both the Neovim and plain-Vim paths.
|
||||
| Nuwiki | Vimwiki | Action |
|
||||
|---|---|---|
|
||||
| `:NuwikiNextTask` | `:VimwikiNextTask` | Jump to the next unfinished task |
|
||||
| `:NuwikiToggleListItem` | `:VimwikiToggleListItem` | Toggle the checkbox under the cursor |
|
||||
| `:NuwikiToggleListItem` | `:VimwikiToggleListItem` | Toggle the checkbox under the cursor (`-range`: every item in the selection) |
|
||||
| `:NuwikiToggleRejected` | `:VimwikiToggleRejectedListItem` | Toggle the rejected marker `[-]` |
|
||||
| `:NuwikiListToggle` | `:VimwikiListToggle` | Toggle a checkbox on the current item (adds `[ ]` if none) |
|
||||
| `:NuwikiIncrementListItem` / `:NuwikiDecrementListItem` | `:VimwikiIncrementListItem` / `:VimwikiDecrementListItem` | Cycle the list marker to the next / previous symbol |
|
||||
| `:NuwikiIncrementListItem` / `:NuwikiDecrementListItem` | `:VimwikiIncrementListItem` / `:VimwikiDecrementListItem` | Cycle the list marker to the next / previous symbol (`-range`: every item in the selection) |
|
||||
| `:NuwikiChangeSymbol {sym}` | `:VimwikiChangeSymbolTo {sym}` (`:VimwikiListChangeSymbolI`) | Set the current item's marker to `{sym}` |
|
||||
| `:NuwikiChangeSymbolInList {sym}` | `:VimwikiChangeSymbolInListTo {sym}` | Set every item in the list to `{sym}` |
|
||||
| `:NuwikiRemoveDone[!]` | `:VimwikiRemoveDone[!]` | Remove every completed item from the current list; with `!`, from the whole buffer |
|
||||
@@ -447,9 +551,9 @@ available on both the Neovim and plain-Vim paths.
|
||||
|---|---|---|
|
||||
| `:NuwikiTOC` | `:VimwikiTOC` | Generate / refresh the table of contents on the current page |
|
||||
| `:NuwikiGenerateLinks` | `:VimwikiGenerateLinks` | Insert a flat list of every page in the wiki |
|
||||
| `:NuwikiCheckLinks` | `:VimwikiCheckLinks` | Send broken links to the quickfix list |
|
||||
| `:NuwikiCheckLinks` | `:VimwikiCheckLinks` | Send broken links to the quickfix list (`-range`: limit to the current buffer's selected lines) |
|
||||
| `:NuwikiFindOrphans` | `:VimwikiFindOrphans` | List pages with no incoming links in the quickfix list |
|
||||
| `:NuwikiRebuildTags` | `:VimwikiRebuildTags` | Force a full workspace re-index |
|
||||
| `:NuwikiRebuildTags[!]` | `:VimwikiRebuildTags[!]` | Re-index the current wiki; `!` re-indexes every configured wiki |
|
||||
| `:NuwikiSearchTags {tag}` | `:VimwikiSearchTags {tag}` | Quickfix listing every occurrence of `:tag:` |
|
||||
| `:NuwikiGenerateTagLinks [tag]` | `:VimwikiGenerateTagLinks [tag]` (`:VimwikiGenerateTags`) | Insert a section linking every page that has `<tag>` |
|
||||
| `:NuwikiColorize {color}` | `:VimwikiColorize {color}` | Wrap the word / visual selection in a coloured `<span>` |
|
||||
@@ -479,7 +583,8 @@ via `mappings.<group> = false` in Neovim, or
|
||||
| `<CR>` | Smart follow — inside `[[…]]` jumps; on a bare word wraps as `[[word]]` (second `<CR>` follows) |
|
||||
| `<S-CR>` | Follow in horizontal split |
|
||||
| `<C-CR>` | Follow in vertical split |
|
||||
| `<C-S-CR>` | Follow in a tab, reusing an existing tab if the file is already open |
|
||||
| `<C-S-CR>` / `<D-CR>` | Follow in a tab, reusing an existing tab if the file is already open (`<D-CR>` is the macOS Cmd alias) |
|
||||
| `<M-CR>` | Add the link target to the buffer list (`:badd`, no jump) |
|
||||
| `<BS>` | Jump back (`<C-o>`) |
|
||||
| `<Tab>` / `<S-Tab>` | Next / previous wikilink on or after the cursor |
|
||||
| `+` | Wrap word / visual selection as a wikilink (no follow) |
|
||||
@@ -512,6 +617,7 @@ vimwiki) they fire only after `'timeoutlen'`; type a suffix such as `gln` or
|
||||
| Key | Action |
|
||||
|---|---|
|
||||
| `<CR>` | Continue the list with the same marker (preserves checkbox), or break out on an empty bullet |
|
||||
| `<S-CR>` | Multiline list item continuation (no new marker) |
|
||||
| `<C-D>` / `<C-T>` | Dedent / indent the current item |
|
||||
| `<C-L><C-J>` / `<C-L><C-K>` | Cycle the marker style (`-` → `*` → `#` → `1.` → `1)` → `a)` → `A)` → `i)` → `I)`) |
|
||||
| `<C-L><C-M>` | Toggle / add a checkbox on the current item |
|
||||
@@ -536,14 +642,17 @@ vimwiki) they fire only after `'timeoutlen'`; type a suffix such as `gln` or
|
||||
|
||||
**Wiki / diary / export** (normal mode)
|
||||
|
||||
The `<Leader>w` prefix below is the default `map_prefix` (`g:nuwiki_map_prefix`); change that option to relocate the whole family.
|
||||
|
||||
| Key | Action |
|
||||
|---|---|
|
||||
| `<Leader>ww` | Open the wiki index |
|
||||
| `<Leader>wt` | Open the wiki index in a new tab |
|
||||
| `<Leader>ws` | Pick a wiki |
|
||||
| `<Leader>wi` | Open the diary index |
|
||||
| `<Leader>w<Leader>w` / `<Leader>w<Leader>y` / `<Leader>w<Leader>t` | Diary today / yesterday / tomorrow |
|
||||
| `<Leader>w<Leader>m` | Diary tomorrow (vimwiki-compat alias of `<Leader>w<Leader>t`) |
|
||||
| `<Leader>w<Leader>w` / `<Leader>w<Leader>y` | Diary today / yesterday |
|
||||
| `<Leader>w<Leader>t` | Diary today, in a new tab |
|
||||
| `<Leader>w<Leader>m` | Diary tomorrow |
|
||||
| `<Leader>w<Leader>i` | Rebuild the diary index page |
|
||||
| `<Leader>wn` / `<Leader>wd` / `<Leader>wr` | Goto / delete / rename page |
|
||||
| `<Leader>wh` / `<Leader>whh` | Export the current page to HTML / open in browser |
|
||||
@@ -587,14 +696,30 @@ match vimwiki. To migrate:
|
||||
|
||||
1. Drop the original `vimwiki` plugin from your config.
|
||||
2. Install nuwiki.
|
||||
3. Point `wiki_root` (or each `wikis[i].root`) at your existing
|
||||
directory.
|
||||
3. Keep your existing config, or point `wiki_root` / `wikis[i].root` at
|
||||
your directory.
|
||||
|
||||
**Existing `g:vimwiki_list` config works as-is (Vim & Neovim).** When you
|
||||
haven't configured nuwiki natively (`g:nuwiki_wikis` / `setup()` / `g:nuwiki_*`),
|
||||
both clients read your upstream `g:vimwiki_list` and `g:vimwiki_*` globals and
|
||||
translate them: each wiki's `path`/`path_html`/`template_*`/`auto_export`
|
||||
plus globals like `toc_header_level`, `html_header_numbering`,
|
||||
`html_header_numbering_sym`, `links_space_char`, and `list_margin`.
|
||||
Native config always takes precedence. (Settings nuwiki implements
|
||||
differently — see [`known-issues.md`](./known-issues.md) — are ignored.)
|
||||
On Neovim with lazy.nvim, swap the `vimwiki` plugin spec for nuwiki and
|
||||
call `require('nuwiki').setup()` — your `vim.g.vimwiki_list` is picked up
|
||||
unchanged.
|
||||
|
||||
Existing pages, diary entries, tags, and templates work without
|
||||
modification. The first workspace scan after launch may show an empty
|
||||
`:VimwikiCheckLinks` result until the background index completes;
|
||||
subsequent runs are instant.
|
||||
|
||||
A few upstream options are implemented differently or not at all — by
|
||||
design, since nuwiki is LSP-backed rather than pure VimL. See
|
||||
[`known-issues.md`](./known-issues.md) for the full list of divergences.
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
" autoload/nuwiki/colors.vim — conceal colour spans down to their text.
|
||||
"
|
||||
" `:VimwikiColorize NAME` wraps text in `<span style="color:NAME">TEXT</span>`.
|
||||
" This hides the two tags (conceal) and paints TEXT in NAME, so the buffer
|
||||
" shows just the coloured word — matching how the HTML export renders it.
|
||||
"
|
||||
" Pure syntax + `:highlight`, no LSP needed, so it works the same in Vim,
|
||||
" coc.nvim, and Neovim. New colours appear as the user runs :VimwikiColorize,
|
||||
" so the ftplugin re-runs refresh() on text changes; we track which colours
|
||||
" already have a syntax group in b:nuwiki_color_seen to stay idempotent.
|
||||
|
||||
" Scan the buffer for `<span style="color:…">` and define a concealing,
|
||||
" colouring syntax region for each colour not yet seen.
|
||||
function! nuwiki#colors#refresh() abort
|
||||
if !exists('b:nuwiki_color_seen')
|
||||
let b:nuwiki_color_seen = {}
|
||||
endif
|
||||
let l:n = line('$')
|
||||
let l:lnum = 1
|
||||
while l:lnum <= l:n
|
||||
let l:line = getline(l:lnum)
|
||||
if stridx(l:line, '<span style="color:') >= 0
|
||||
let l:pos = 0
|
||||
while 1
|
||||
let l:mp = matchstrpos(l:line, '<span style="color:[^"]\{-}">', l:pos)
|
||||
if l:mp[1] < 0
|
||||
break
|
||||
endif
|
||||
let l:pos = l:mp[2]
|
||||
call s:define(matchstr(l:mp[0], 'color:\zs[^"]\{-}\ze">'))
|
||||
endwhile
|
||||
endif
|
||||
let l:lnum += 1
|
||||
endwhile
|
||||
endfunction
|
||||
|
||||
" Define (once per colour) a region that conceals `<span …>` / `</span>` and
|
||||
" paints the wrapped text in that colour.
|
||||
function! s:define(color) abort
|
||||
if a:color ==# '' || has_key(b:nuwiki_color_seen, a:color)
|
||||
return
|
||||
endif
|
||||
let b:nuwiki_color_seen[a:color] = 1
|
||||
" Only handle colours Vim can actually allocate: a #hex value or an
|
||||
" alphabetic name. Anything else — rgb()/hsl() functions, or a literal
|
||||
" `<span style="color:…">` that turns up in prose/examples — is skipped, so
|
||||
" a stray match can't create a bogus conceal region or abort the refresh.
|
||||
if a:color !~# '^#\x\{3,8}$' && a:color !~# '^[A-Za-z][A-Za-z0-9]*$'
|
||||
return
|
||||
endif
|
||||
let l:grp = 'nuwikiColorSpan_' . substitute(a:color, '[^A-Za-z0-9]', '_', 'g')
|
||||
let l:esc = escape(a:color, '/\.*$^~[]')
|
||||
" Clear first so re-runs / load-order never stack duplicate regions.
|
||||
silent! execute 'syntax clear ' . l:grp
|
||||
" `concealends` hides the start/end matches (the tags); the region body
|
||||
" (the text) keeps the l:grp highlight. `oneline` since spans don't wrap.
|
||||
execute 'syntax region ' . l:grp
|
||||
\ . ' matchgroup=Conceal'
|
||||
\ . ' start=/<span style="color:' . l:esc . '">/'
|
||||
\ . ' end=#</span>#'
|
||||
\ . ' oneline keepend concealends'
|
||||
" guifg works for names and #hex under 'termguicolors'; ctermfg only for
|
||||
" named colours. Both are best-effort — `silent!` swallows E254 for a name
|
||||
" Vim doesn't know (the tags still conceal, the text just isn't recoloured).
|
||||
silent! execute 'highlight default ' . l:grp . ' guifg=' . a:color
|
||||
if a:color !~# '^#'
|
||||
silent! execute 'highlight ' . l:grp . ' ctermfg=' . a:color
|
||||
endif
|
||||
endfunction
|
||||
+424
-89
@@ -168,6 +168,133 @@ endfunction
|
||||
" from the server, so the picker works from any buffer before a wiki file is
|
||||
" ever opened — opening the chosen index then auto-starts the LSP via the
|
||||
" FileType autocmd. Plain Vim has no `vim.ui.select`, so use `inputlist()`.
|
||||
" Absolute root (with trailing slash) of the wiki that owns `a:file`, or the
|
||||
" first configured wiki as a fallback. Used by auto_chdir + search scoping.
|
||||
" Absolute root (trailing slash) of the wiki that owns `a:file`, or '' when the
|
||||
" file is under no configured wiki (callers no-op / fall back to CWD).
|
||||
function! nuwiki#commands#wiki_root_for(file) abort
|
||||
for l:w in nuwiki#commands#wiki_list()
|
||||
let l:wroot = fnamemodify(expand(l:w.root), ':p')
|
||||
if l:wroot[len(l:wroot) - 1:] !=# '/' | let l:wroot .= '/' | endif
|
||||
if a:file[: len(l:wroot) - 1] ==# l:wroot
|
||||
return l:wroot
|
||||
endif
|
||||
endfor
|
||||
return ''
|
||||
endfunction
|
||||
|
||||
" :NuwikiAutoChdir hook — `:lcd` into the owning wiki's root (vimwiki
|
||||
" `auto_chdir`). Wired from a buffer-local autocmd in the ftplugin.
|
||||
function! nuwiki#commands#auto_chdir() abort
|
||||
let l:root = nuwiki#commands#wiki_root_for(expand('%:p'))
|
||||
if l:root !=# ''
|
||||
execute 'lcd ' . fnameescape(l:root)
|
||||
endif
|
||||
endfunction
|
||||
|
||||
" vimwiki `auto_header`: on a new wiki page, insert a level-1 header derived
|
||||
" from the filename. Skips the wiki index + diary index pages; only acts on an
|
||||
" empty new buffer. Wired from a BufNewFile autocmd.
|
||||
function! nuwiki#commands#auto_header() abort
|
||||
if line('$') > 1 || getline(1) !=# ''
|
||||
return
|
||||
endif
|
||||
let l:file = expand('%:p')
|
||||
let l:space = ' '
|
||||
for l:c in nuwiki#commands#wiki_list()
|
||||
let l:root = fnamemodify(expand(l:c.root), ':p')
|
||||
if l:root[len(l:root) - 1:] !=# '/' | let l:root .= '/' | endif
|
||||
if l:file[: len(l:root) - 1] ==# l:root
|
||||
let l:index_path = l:root . l:c.idx . l:c.ext
|
||||
let l:diary_index_path = l:root . l:c.diary_rel . '/' . l:c.diary_idx . l:c.ext
|
||||
if l:file ==# l:index_path || l:file ==# l:diary_index_path
|
||||
return
|
||||
endif
|
||||
let l:space = get(l:c, 'space', ' ')
|
||||
break
|
||||
endif
|
||||
endfor
|
||||
" Convert links_space_char back to spaces for the title (vimwiki parity).
|
||||
let l:title = expand('%:t:r')
|
||||
if l:space !=# ' ' && l:space !=# ''
|
||||
let l:title = substitute(l:title, '\V' . escape(l:space, '\'), ' ', 'g')
|
||||
endif
|
||||
call setline(1, '= ' . l:title . ' =')
|
||||
call append(1, '')
|
||||
endfunction
|
||||
|
||||
" :VimwikiShowVersion / :NuwikiShowVersion — echo the nuwiki version and the
|
||||
" host editor, mirroring upstream's version banner.
|
||||
function! nuwiki#commands#show_version() abort
|
||||
echo 'nuwiki: ' . get(g:, 'nuwiki_version', '?')
|
||||
echo (has('nvim') ? 'Neovim' : 'Vim') . ': ' . v:version
|
||||
endfunction
|
||||
|
||||
" :VimwikiSearch / :VWS {pattern} — search the current wiki's files (upstream's
|
||||
" search scopes to the wiki, not the editor's CWD). Resolves the wiki whose root
|
||||
" contains the current file; falls back to the first configured wiki, then to
|
||||
" the CWD. An empty pattern reuses the last search (`@/`). Populates the
|
||||
" location list.
|
||||
function! nuwiki#commands#search(args) abort
|
||||
let l:pat = a:args ==# '' ? @/ : a:args
|
||||
if l:pat ==# ''
|
||||
echohl WarningMsg | echom 'nuwiki: no search pattern' | echohl None
|
||||
return
|
||||
endif
|
||||
" Resolve { root, ext } for the current buffer's wiki.
|
||||
let l:file = expand('%:p')
|
||||
let l:root = ''
|
||||
let l:ext = get(g:, 'nuwiki_file_extension', '.wiki')
|
||||
for l:w in nuwiki#commands#wiki_list()
|
||||
let l:wroot = fnamemodify(expand(l:w.root), ':p')
|
||||
if l:file[: len(l:wroot) - 1] ==# l:wroot
|
||||
let l:root = l:wroot
|
||||
let l:ext = get(l:w, 'ext', l:ext)
|
||||
break
|
||||
endif
|
||||
endfor
|
||||
if l:root ==# '' && !empty(nuwiki#commands#wiki_list())
|
||||
let l:first = nuwiki#commands#wiki_list()[0]
|
||||
let l:root = fnamemodify(expand(l:first.root), ':p')
|
||||
let l:ext = get(l:first, 'ext', l:ext)
|
||||
endif
|
||||
let l:ext = l:ext[0] ==# '.' ? l:ext : '.' . l:ext
|
||||
let l:glob = l:root ==# '' ? '**' : fnameescape(l:root) . '**/*' . l:ext
|
||||
try
|
||||
execute 'lvimgrep /' . escape(l:pat, '/') . '/j ' . l:glob
|
||||
catch /E480/
|
||||
echohl WarningMsg | echom 'nuwiki: no match for ' . l:pat | echohl None
|
||||
return
|
||||
endtry
|
||||
lopen
|
||||
endfunction
|
||||
|
||||
" :VimwikiVar / :NuwikiVar [key [value]] — get/set the nuwiki client globals
|
||||
" (g:nuwiki_*), mirroring upstream's vimwiki#vars#cmd. No arg prints every
|
||||
" g:nuwiki_* var; one arg prints that var; two+ sets it (as a string — server-
|
||||
" backed settings need a restart to take effect, which we note).
|
||||
function! nuwiki#commands#var(arg) abort
|
||||
let l:parts = split(a:arg, '\s\+')
|
||||
if empty(l:parts)
|
||||
let l:keys = sort(filter(keys(g:), 'v:val =~# "^nuwiki_"'))
|
||||
if empty(l:keys)
|
||||
echo 'nuwiki: no g:nuwiki_* variables set'
|
||||
return
|
||||
endif
|
||||
for l:k in l:keys
|
||||
echo printf('g:%s = %s', l:k, string(g:[l:k]))
|
||||
endfor
|
||||
return
|
||||
endif
|
||||
let l:key = 'nuwiki_' . substitute(l:parts[0], '^nuwiki_', '', '')
|
||||
if len(l:parts) == 1
|
||||
echo printf('g:%s = %s', l:key, exists('g:' . l:key) ? string(g:[l:key]) : '(unset)')
|
||||
else
|
||||
let g:[l:key] = join(l:parts[1:], ' ')
|
||||
echo printf('set g:%s = %s (restart for server-backed settings)', l:key, string(g:[l:key]))
|
||||
endif
|
||||
endfunction
|
||||
|
||||
function! nuwiki#commands#wiki_ui_select() abort
|
||||
let l:wikis = nuwiki#commands#wiki_list()
|
||||
if empty(l:wikis)
|
||||
@@ -341,15 +468,7 @@ endfunction
|
||||
" ===== Heading navigation (pure VimL) =====
|
||||
|
||||
function! s:heading_level(line) abort
|
||||
let l:lead = matchstr(a:line, '^\s*\zs=\+\ze\s')
|
||||
if l:lead ==# ''
|
||||
return 0
|
||||
endif
|
||||
let l:trail = matchstr(a:line, '\s\zs=\+\ze\s*$')
|
||||
if len(l:trail) != len(l:lead)
|
||||
return 0
|
||||
endif
|
||||
return len(l:lead)
|
||||
return nuwiki#util#heading_level(a:line)
|
||||
endfunction
|
||||
|
||||
function! s:current_heading_level() abort
|
||||
@@ -419,32 +538,37 @@ endfunction
|
||||
|
||||
" ===== Diary =====
|
||||
|
||||
function! s:diary_open(cmd_name, ...) abort
|
||||
let l:open = a:0 >= 1 ? a:1 : 'edit'
|
||||
call s:exec(a:cmd_name, [{ 'uri': s:buf_uri() }],
|
||||
\ {n -> s:open_uri_from(n, l:open)})
|
||||
" `a:open` is the window command ('edit'/'tabedit'); `a:count` is vimwiki's
|
||||
" diary `-count` — a wiki number (1-indexed at the user level). When > 0 the
|
||||
" server acts on that wiki instead of the buffer's.
|
||||
function! s:diary_open(cmd_name, open, count) abort
|
||||
let l:arg = { 'uri': s:buf_uri() }
|
||||
if a:count > 0
|
||||
let l:arg['wiki'] = a:count - 1
|
||||
endif
|
||||
call s:exec(a:cmd_name, [l:arg], {n -> s:open_uri_from(n, a:open)})
|
||||
endfunction
|
||||
|
||||
function! nuwiki#commands#diary_today() abort
|
||||
call s:diary_open('nuwiki.diary.openToday')
|
||||
function! nuwiki#commands#diary_today(...) abort
|
||||
call s:diary_open('nuwiki.diary.openToday', 'edit', a:0 >= 1 ? a:1 : 0)
|
||||
endfunction
|
||||
function! nuwiki#commands#diary_today_tab() abort
|
||||
call s:diary_open('nuwiki.diary.openToday', 'tabedit')
|
||||
function! nuwiki#commands#diary_today_tab(...) abort
|
||||
call s:diary_open('nuwiki.diary.openToday', 'tabedit', a:0 >= 1 ? a:1 : 0)
|
||||
endfunction
|
||||
function! nuwiki#commands#diary_yesterday() abort
|
||||
call s:diary_open('nuwiki.diary.openYesterday')
|
||||
function! nuwiki#commands#diary_yesterday(...) abort
|
||||
call s:diary_open('nuwiki.diary.openYesterday', 'edit', a:0 >= 1 ? a:1 : 0)
|
||||
endfunction
|
||||
function! nuwiki#commands#diary_tomorrow() abort
|
||||
call s:diary_open('nuwiki.diary.openTomorrow')
|
||||
function! nuwiki#commands#diary_tomorrow(...) abort
|
||||
call s:diary_open('nuwiki.diary.openTomorrow', 'edit', a:0 >= 1 ? a:1 : 0)
|
||||
endfunction
|
||||
function! nuwiki#commands#diary_index() abort
|
||||
call s:diary_open('nuwiki.diary.openIndex')
|
||||
function! nuwiki#commands#diary_index(...) abort
|
||||
call s:diary_open('nuwiki.diary.openIndex', 'edit', a:0 >= 1 ? a:1 : 0)
|
||||
endfunction
|
||||
function! nuwiki#commands#diary_next() abort
|
||||
call s:diary_open('nuwiki.diary.next')
|
||||
call s:diary_open('nuwiki.diary.next', 'edit', 0)
|
||||
endfunction
|
||||
function! nuwiki#commands#diary_prev() abort
|
||||
call s:diary_open('nuwiki.diary.prev')
|
||||
call s:diary_open('nuwiki.diary.prev', 'edit', 0)
|
||||
endfunction
|
||||
function! nuwiki#commands#diary_generate_index() abort
|
||||
call s:exec('nuwiki.diary.generateIndex', [{ 'uri': s:buf_uri() }])
|
||||
@@ -456,36 +580,24 @@ endfunction
|
||||
" open their wiki from any buffer. Files are opened directly from config;
|
||||
" the LSP auto-starts via the FileType autocmd once the buffer loads.
|
||||
|
||||
" Return a dict with {root, ext, idx, diary_rel, diary_idx} for wiki #n
|
||||
" (0-based). Prefers g:nuwiki_wikis[n]; falls back to scalar g:nuwiki_*
|
||||
" vars and finally to built-in defaults.
|
||||
" Return a dict with {root, ext, idx, diary_rel, diary_idx, space} for wiki #n
|
||||
" (0-based). Resolves through nuwiki#config#wikis() — so g:nuwiki_wikis AND an
|
||||
" upstream g:vimwiki_list both work — and falls back to the scalar g:nuwiki_*
|
||||
" vars (single-wiki shorthand) and finally built-in defaults.
|
||||
function! s:wiki_cfg(n) abort
|
||||
let l:w = {}
|
||||
if exists('g:nuwiki_wikis') && len(g:nuwiki_wikis) > a:n
|
||||
let l:w = g:nuwiki_wikis[a:n]
|
||||
endif
|
||||
let l:root = expand(get(l:w, 'root',
|
||||
\ get(g:, 'nuwiki_wiki_root', '~/vimwiki')))
|
||||
let l:ext = get(l:w, 'file_extension',
|
||||
\ get(g:, 'nuwiki_file_extension', '.wiki'))
|
||||
if l:ext[0] !=# '.' | let l:ext = '.' . l:ext | endif
|
||||
let l:idx = get(l:w, 'index', 'index')
|
||||
let l:diary_rel = get(l:w, 'diary_rel_path',
|
||||
\ get(g:, 'nuwiki_diary_rel_path', 'diary'))
|
||||
let l:diary_idx = get(l:w, 'diary_index', 'diary')
|
||||
return { 'root': l:root, 'ext': l:ext, 'idx': l:idx,
|
||||
\ 'diary_rel': l:diary_rel, 'diary_idx': l:diary_idx }
|
||||
return nuwiki#util#wiki_cfg(a:n)
|
||||
endfunction
|
||||
|
||||
" Build the list of configured wikis as {name, root, ext, idx, diary_rel,
|
||||
" diary_idx} dicts, straight from config — no LSP round-trip. Drives the wiki
|
||||
" picker so it works before any wiki buffer (and therefore the server) exists.
|
||||
" Falls back to a single entry from the scalar `g:nuwiki_wiki_root` config.
|
||||
" diary_idx, space} dicts, straight from config — no LSP round-trip. Drives the
|
||||
" wiki picker so it works before any wiki buffer (and therefore the server)
|
||||
" exists. Falls back to a single entry from the scalar `g:nuwiki_wiki_root`.
|
||||
function! nuwiki#commands#wiki_list() abort
|
||||
let l:wikis = nuwiki#config#wikis()
|
||||
let l:list = []
|
||||
if exists('g:nuwiki_wikis') && !empty(g:nuwiki_wikis)
|
||||
if !empty(l:wikis)
|
||||
let l:n = 0
|
||||
for l:w in g:nuwiki_wikis
|
||||
for l:w in l:wikis
|
||||
let l:c = s:wiki_cfg(l:n)
|
||||
let l:c['name'] = get(l:w, 'name', fnamemodify(l:c.root, ':t'))
|
||||
call add(l:list, l:c)
|
||||
@@ -529,6 +641,29 @@ endfunction
|
||||
function! nuwiki#commands#toggle_list_item() abort
|
||||
call s:exec_pos('nuwiki.list.toggleCheckbox')
|
||||
endfunction
|
||||
|
||||
" Apply a per-line action across [l1, l2] (visual-range commands). Each call
|
||||
" sends its own request for its line; the server's edits are version-less and
|
||||
" single-line, so applying them as they return never conflicts.
|
||||
function! s:over_range(l1, l2, Fn) abort
|
||||
let l:save = getcurpos()
|
||||
for l:ln in range(a:l1, a:l2)
|
||||
call cursor(l:ln, 1)
|
||||
call a:Fn()
|
||||
endfor
|
||||
call setpos('.', l:save)
|
||||
endfunction
|
||||
|
||||
function! nuwiki#commands#toggle_list_item_range(l1, l2) abort
|
||||
call s:over_range(a:l1, a:l2, function('nuwiki#commands#toggle_list_item'))
|
||||
endfunction
|
||||
|
||||
function! nuwiki#commands#list_cycle_symbol_range(direction, l1, l2) abort
|
||||
call s:over_range(a:l1, a:l2, { -> nuwiki#commands#list_cycle_symbol(a:direction) })
|
||||
endfunction
|
||||
function! nuwiki#commands#reject_list_item_range(l1, l2) abort
|
||||
call s:over_range(a:l1, a:l2, function('nuwiki#commands#reject_list_item'))
|
||||
endfunction
|
||||
function! nuwiki#commands#cycle_list_item() abort
|
||||
call s:exec_pos('nuwiki.list.cycleCheckbox')
|
||||
endfunction
|
||||
@@ -568,14 +703,31 @@ endfunction
|
||||
" ===== Generation + workspace =====
|
||||
|
||||
function! nuwiki#commands#toc_generate() abort
|
||||
call s:exec('nuwiki.toc.generate', [{'uri': s:buf_uri()}])
|
||||
" Send the cursor line (0-based) so a fresh TOC is inserted there.
|
||||
call s:exec('nuwiki.toc.generate',
|
||||
\ [{'uri': s:buf_uri(), 'line': line('.') - 1}])
|
||||
endfunction
|
||||
function! nuwiki#commands#links_generate() abort
|
||||
call s:exec('nuwiki.links.generate', [{'uri': s:buf_uri()}])
|
||||
" `: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}])
|
||||
else
|
||||
call s:exec('nuwiki.links.generate',
|
||||
\ [{'uri': s:buf_uri(), 'line': l:line}])
|
||||
endif
|
||||
endfunction
|
||||
function! nuwiki#commands#check_links() abort
|
||||
function! nuwiki#commands#check_links(...) abort
|
||||
" a:1 = range count; when >0, restrict the report to the current buffer's
|
||||
" [a:2, a:3] lines (`:'<,'>VimwikiCheckLinks`). No range → whole workspace.
|
||||
let l:rng = (a:0 >= 3 && a:1 > 0) ? [a:2, a:3] : []
|
||||
call s:exec('nuwiki.workspace.checkLinks', [{ 'uri': s:buf_uri() }],
|
||||
\ {n -> s:results_to_qf(n, 'broken links')})
|
||||
\ {n -> s:results_to_qf(n, 'broken links', l:rng)})
|
||||
endfunction
|
||||
|
||||
function! nuwiki#commands#find_orphans() abort
|
||||
@@ -583,7 +735,10 @@ function! nuwiki#commands#find_orphans() abort
|
||||
\ {n -> s:results_to_qf(n, 'orphans')})
|
||||
endfunction
|
||||
|
||||
function! s:results_to_qf(notification, title) abort
|
||||
function! s:results_to_qf(notification, title, ...) abort
|
||||
" Optional a:1 = [line1, line2]: keep only current-buffer rows in that range.
|
||||
let l:rng = a:0 >= 1 ? a:1 : []
|
||||
let l:cur = empty(l:rng) ? '' : resolve(expand('%:p'))
|
||||
let l:rows = get(get(a:notification, 'response', {}), 'result', [])
|
||||
if type(l:rows) != type([]) || empty(l:rows)
|
||||
echom 'nuwiki: no ' . a:title
|
||||
@@ -598,9 +753,18 @@ function! s:results_to_qf(notification, title) abort
|
||||
let l:lnum = l:row['range']['start']['line'] + 1
|
||||
let l:col = l:row['range']['start']['character'] + 1
|
||||
endif
|
||||
if !empty(l:rng)
|
||||
if resolve(l:path) !=# l:cur || l:lnum < l:rng[0] || l:lnum > l:rng[1]
|
||||
continue
|
||||
endif
|
||||
endif
|
||||
let l:text = get(l:row, 'message', get(l:row, 'name', ''))
|
||||
call add(l:items, { 'filename': l:path, 'lnum': l:lnum, 'col': l:col, 'text': l:text })
|
||||
endfor
|
||||
if empty(l:items)
|
||||
echom 'nuwiki: no ' . a:title
|
||||
return
|
||||
endif
|
||||
call setqflist([], ' ', { 'title': 'nuwiki ' . a:title, 'items': l:items })
|
||||
copen
|
||||
endfunction
|
||||
@@ -617,15 +781,19 @@ function! nuwiki#commands#tags_search(query) abort
|
||||
endfunction
|
||||
|
||||
function! nuwiki#commands#tags_generate_links(tag) abort
|
||||
let l:args = { 'uri': s:buf_uri() }
|
||||
" Cursor line (0-based) so a fresh section is inserted there.
|
||||
let l:args = { 'uri': s:buf_uri(), 'line': line('.') - 1 }
|
||||
if a:tag !=# ''
|
||||
let l:args['tag'] = a:tag
|
||||
endif
|
||||
call s:exec('nuwiki.tags.generateLinks', [l:args])
|
||||
endfunction
|
||||
|
||||
function! nuwiki#commands#tags_rebuild() abort
|
||||
call s:exec('nuwiki.tags.rebuild', [{ 'uri': s:buf_uri() }])
|
||||
function! nuwiki#commands#tags_rebuild(...) abort
|
||||
" a:1 (the command's <bang>) → re-index every configured wiki.
|
||||
let l:all = a:0 >= 1 && a:1
|
||||
call s:exec('nuwiki.tags.rebuild',
|
||||
\ [{ 'uri': s:buf_uri(), 'all': l:all ? v:true : v:false }])
|
||||
endfunction
|
||||
|
||||
" ===== HTML export =====
|
||||
@@ -706,6 +874,26 @@ function! nuwiki#commands#list_remove_done_all() abort
|
||||
call s:exec('nuwiki.list.removeDone', l:args)
|
||||
endfunction
|
||||
|
||||
" `:'<,'>VimwikiRemoveDone` — remove done items only within the given line
|
||||
" range (1-indexed, inclusive; converted to the server's 0-indexed range).
|
||||
function! nuwiki#commands#list_remove_done_range(l1, l2) abort
|
||||
let l:args = [{ 'uri': s:buf_uri(), 'range': [a:l1 - 1, a:l2 - 1] }]
|
||||
call s:exec('nuwiki.list.removeDone', l:args)
|
||||
endfunction
|
||||
|
||||
" `:[range]VimwikiRemoveDone[!]` dispatcher. `!` → whole buffer; an explicit
|
||||
" range (`a:range > 0`, e.g. `:'<,'>`) → that line span; otherwise the
|
||||
" current list under the cursor. Mirrors upstream's `-bang -range` form.
|
||||
function! nuwiki#commands#remove_done(bang, range, l1, l2) abort
|
||||
if a:bang
|
||||
call nuwiki#commands#list_remove_done_all()
|
||||
elseif a:range > 0
|
||||
call nuwiki#commands#list_remove_done_range(a:l1, a:l2)
|
||||
else
|
||||
call nuwiki#commands#list_remove_done()
|
||||
endif
|
||||
endfunction
|
||||
|
||||
" `:VimwikiRemoveSingleCB` — strip the checkbox from the current item only.
|
||||
function! nuwiki#commands#list_remove_checkbox() abort
|
||||
let l:p = s:cursor_position()
|
||||
@@ -751,6 +939,13 @@ function! nuwiki#commands#list_change_symbol(symbol, whole_list) abort
|
||||
call s:exec('nuwiki.list.changeSymbol', l:args)
|
||||
endfunction
|
||||
|
||||
" `:[range]VimwikiChangeSymbolTo {sym}` / `:[range]VimwikiListChangeSymbolI {sym}`
|
||||
" — upstream is `-range -nargs=1`; a range sets the marker on every list item in
|
||||
" the selection (single-item when no range, since line1==line2==cursor line).
|
||||
function! nuwiki#commands#list_change_symbol_range(symbol, l1, l2) abort
|
||||
call s:over_range(a:l1, a:l2, { -> nuwiki#commands#list_change_symbol(a:symbol, 0) })
|
||||
endfunction
|
||||
|
||||
function! nuwiki#commands#list_change_level(delta, whole_subtree) abort
|
||||
let l:p = s:cursor_position()
|
||||
let l:args = [{
|
||||
@@ -762,11 +957,15 @@ function! nuwiki#commands#list_change_level(delta, whole_subtree) abort
|
||||
call s:exec('nuwiki.list.changeLevel', l:args)
|
||||
endfunction
|
||||
|
||||
" `:VimwikiListChangeLvl decrease|increase 0` compat entry.
|
||||
function! nuwiki#commands#list_change_lvl(...) abort
|
||||
let l:dir = a:0 >= 1 ? a:1 : 'increase'
|
||||
let l:delta = (l:dir ==# 'increase' || l:dir ==# 'indent') ? 1 : -1
|
||||
call nuwiki#commands#list_change_level(l:delta, 0)
|
||||
" `:[range]VimwikiListChangeLvl {increase|decrease} [plus_children]` — upstream
|
||||
" is `-range -nargs=+`: direction is required, the optional second arg cascades
|
||||
" the level change to each item's subtree, and a range applies it to every list
|
||||
" item in the selection.
|
||||
function! nuwiki#commands#list_change_lvl(line1, line2, direction, ...) abort
|
||||
let l:delta = (a:direction ==# 'increase' || a:direction ==# 'indent') ? 1 : -1
|
||||
let l:children = a:0 >= 1 ? (str2nr(a:1) != 0) : 0
|
||||
call s:over_range(a:line1, a:line2,
|
||||
\ { -> nuwiki#commands#list_change_level(l:delta, l:children) })
|
||||
endfunction
|
||||
|
||||
" Cycle the list marker through vimwiki's canonical order.
|
||||
@@ -812,10 +1011,10 @@ endfunction
|
||||
" Cluster B — table rewriters.
|
||||
|
||||
function! nuwiki#commands#table_insert(...) abort
|
||||
let l:cols = a:0 >= 1 ? str2nr(a:1) : 3
|
||||
let l:cols = a:0 >= 1 ? str2nr(a:1) : 5
|
||||
let l:rows = a:0 >= 2 ? str2nr(a:2) : 2
|
||||
if l:cols <= 0
|
||||
let l:cols = 3
|
||||
let l:cols = 5
|
||||
endif
|
||||
if l:rows <= 0
|
||||
let l:rows = 2
|
||||
@@ -836,6 +1035,18 @@ function! nuwiki#commands#table_align() abort
|
||||
call s:exec('nuwiki.table.align', l:args)
|
||||
endfunction
|
||||
|
||||
" Upstream `gqq` (AlignQ) / `gww` (AlignW): on a table row, align the table;
|
||||
" otherwise fall back to the native Vim format command (`normal! gqq` keeps the
|
||||
" cursor free; `gww` keeps the cursor in place). `normal!` bypasses our own
|
||||
" mapping so there's no recursion.
|
||||
function! nuwiki#commands#table_align_or_cmd(cmd) abort
|
||||
if s:is_table_row(getline('.'))
|
||||
call nuwiki#commands#table_align()
|
||||
else
|
||||
execute 'normal! ' . a:cmd
|
||||
endif
|
||||
endfunction
|
||||
|
||||
function! nuwiki#commands#table_move_left() abort
|
||||
let l:p = s:cursor_position()
|
||||
let l:args = [{
|
||||
@@ -855,18 +1066,53 @@ function! nuwiki#commands#table_move_right() abort
|
||||
\ }]
|
||||
call s:exec('nuwiki.table.moveColumn', l:args)
|
||||
endfunction
|
||||
" `:VimwikiColorize {color}` — pure-VimL wrap as inline span tag,
|
||||
" matching vimwiki's `<span style="color:%c">%s</span>` template.
|
||||
" `:VimwikiColorize {color}` — wrap text in an inline colour span, matching
|
||||
" vimwiki's `<span style="color:%c">%s</span>` template. Wraps the visual
|
||||
" selection when invoked with a range (`:'<,'>VimwikiColorize` / the `x`-mode
|
||||
" <Leader>wc mapping), otherwise the word under the cursor.
|
||||
" a:1 = colour (empty → prompt); a:2 = range count (>0 → use the selection).
|
||||
function! nuwiki#commands#colorize(...) abort
|
||||
let l:color = a:0 >= 1 ? a:1 : ''
|
||||
let l:visual = a:0 >= 2 && a:2 > 0
|
||||
if l:color ==# ''
|
||||
let l:color = input('Colour: ')
|
||||
endif
|
||||
if l:color ==# ''
|
||||
return
|
||||
endif
|
||||
let l:open_tag = '<span style="color:' . l:color . '">'
|
||||
let l:close_tag = '</span>'
|
||||
" vimwiki `color_tag_template`: split on __CONTENT__ into open/close, with
|
||||
" __COLORFG__ replaced by the colour. Configurable via g:nuwiki_color_tag_template.
|
||||
let l:tpl = get(g:, 'nuwiki_color_tag_template',
|
||||
\ '<span style="color:__COLORFG__">__CONTENT__</span>')
|
||||
" Escape `\`, `&`, `~` so a colour containing them isn't treated as a
|
||||
" substitute() replacement metacharacter.
|
||||
let l:tpl = substitute(l:tpl, '__COLORFG__', escape(l:color, '\&~'), 'g')
|
||||
let l:parts = split(l:tpl, '__CONTENT__', 1)
|
||||
let l:open_tag = get(l:parts, 0, '')
|
||||
let l:close_tag = get(l:parts, 1, '')
|
||||
|
||||
if l:visual
|
||||
let l:sp = getpos("'<")
|
||||
let l:ep = getpos("'>")
|
||||
if l:sp[1] > 0 && l:sp[1] == l:ep[1]
|
||||
let l:line = getline(l:sp[1])
|
||||
let l:sc = l:sp[2]
|
||||
let l:ec = l:ep[2]
|
||||
if l:ec > strlen(l:line)
|
||||
let l:ec = strlen(l:line)
|
||||
endif
|
||||
if l:ec >= l:sc
|
||||
let l:new = strpart(l:line, 0, l:sc - 1)
|
||||
\ . l:open_tag . strpart(l:line, l:sc - 1, l:ec - l:sc + 1) . l:close_tag
|
||||
\ . strpart(l:line, l:ec)
|
||||
call setline(l:sp[1], l:new)
|
||||
call nuwiki#colors#refresh()
|
||||
return
|
||||
endif
|
||||
endif
|
||||
" Multi-line or empty selection: fall through to the cword.
|
||||
endif
|
||||
|
||||
let l:line = getline('.')
|
||||
let l:col = col('.')
|
||||
" Word boundaries around cursor (1-based).
|
||||
@@ -886,6 +1132,7 @@ function! nuwiki#commands#colorize(...) abort
|
||||
\ . l:open_tag . l:word . l:close_tag
|
||||
\ . strpart(l:line, l:right - 1)
|
||||
call setline('.', l:new)
|
||||
call nuwiki#colors#refresh()
|
||||
endfunction
|
||||
" Cluster C — link helpers.
|
||||
|
||||
@@ -906,25 +1153,51 @@ endfunction
|
||||
" Mirrors `lua/nuwiki/commands.lua`'s `align_table_at` so plain Vim
|
||||
" buffers get the same "tighten columns as cells grow" behaviour the
|
||||
" Neovim path enjoys, without needing a vim-lsp roundtrip. Algorithm
|
||||
" matches `crates/nuwiki-lsp/src/commands.rs::ops::render_aligned_table`.
|
||||
" matches the table-alignment logic in the nuwiki-rs server.
|
||||
|
||||
function! s:is_table_row(line) abort
|
||||
return a:line =~# '^\s*|.*|\s*$'
|
||||
return nuwiki#util#is_table_row(a:line)
|
||||
endfunction
|
||||
|
||||
" 0-based byte offsets of the cell-separator `|` in `s`. Pipes that are literal
|
||||
" cell content are skipped: those inside `[[…]]` wikilinks, `{{…}}`
|
||||
" transclusions, and `` `…` `` inline code. Byte-based (strpart/stridx) to match
|
||||
" the rest of the table code. Keeps a `[[url|title]]` link in one cell instead
|
||||
" of splitting on its internal pipe.
|
||||
"
|
||||
" Backslash-escaped `\|` is intentionally NOT skipped, to match the server
|
||||
" lexer (nuwiki-rs). See the nuwiki-rs issue for the coordinated `\|` follow-up.
|
||||
function! s:cell_bar_positions(s) abort
|
||||
let l:out = []
|
||||
let l:i = 0
|
||||
let l:n = strlen(a:s)
|
||||
while l:i < l:n
|
||||
let l:c = strpart(a:s, l:i, 1)
|
||||
let l:two = strpart(a:s, l:i, 2)
|
||||
if l:c ==# '`'
|
||||
let l:close = stridx(a:s, '`', l:i + 1)
|
||||
let l:i = l:close < 0 ? l:n : l:close + 1
|
||||
elseif l:two ==# '[['
|
||||
let l:close = stridx(a:s, ']]', l:i + 2)
|
||||
let l:i = l:close < 0 ? l:n : l:close + 2
|
||||
elseif l:two ==# '{{'
|
||||
let l:close = stridx(a:s, '}}', l:i + 2)
|
||||
let l:i = l:close < 0 ? l:n : l:close + 2
|
||||
elseif l:c ==# '|'
|
||||
call add(l:out, l:i)
|
||||
let l:i += 1
|
||||
else
|
||||
let l:i += 1
|
||||
endif
|
||||
endwhile
|
||||
return l:out
|
||||
endfunction
|
||||
|
||||
function! s:table_cell_starts(line) abort
|
||||
" 1-based byte positions of each `|`. Cursor lands one past pipe N at
|
||||
" 1-based byte (starts[N] + 1) — that's the first space inside the
|
||||
" cell the pipe opens.
|
||||
let l:out = []
|
||||
let l:pos = 0
|
||||
while 1
|
||||
let l:bar = stridx(a:line, '|', l:pos)
|
||||
if l:bar < 0 | break | endif
|
||||
call add(l:out, l:bar + 1)
|
||||
let l:pos = l:bar + 1
|
||||
endwhile
|
||||
return l:out
|
||||
return map(s:cell_bar_positions(a:line), 'v:val + 1')
|
||||
endfunction
|
||||
|
||||
function! s:find_table_range(row) abort
|
||||
@@ -945,15 +1218,11 @@ function! s:parse_table_row(line) abort
|
||||
let l:body = strpart(a:line, len(l:indent))
|
||||
let l:segments = []
|
||||
let l:pos = 0
|
||||
while 1
|
||||
let l:bar = stridx(l:body, '|', l:pos)
|
||||
if l:bar < 0
|
||||
call add(l:segments, strpart(l:body, l:pos))
|
||||
break
|
||||
endif
|
||||
for l:bar in s:cell_bar_positions(l:body)
|
||||
call add(l:segments, strpart(l:body, l:pos, l:bar - l:pos))
|
||||
let l:pos = l:bar + 1
|
||||
endwhile
|
||||
endfor
|
||||
call add(l:segments, strpart(l:body, l:pos))
|
||||
" Drop the empty piece before the opening `|` and the trailing piece
|
||||
" after the closing `|`.
|
||||
if len(l:segments) > 0 && l:segments[0] ==# ''
|
||||
@@ -1070,6 +1339,8 @@ endfunction
|
||||
" returned string is fed into Vim's input stream after the function
|
||||
" finishes, keeping the undo block coherent.
|
||||
function! nuwiki#commands#smart_return() abort
|
||||
" Accept the completion (plain <CR>) when the popup menu is open, like upstream.
|
||||
if pumvisible() | return "\<CR>" | endif
|
||||
let l:line = getline('.')
|
||||
" `<expr>` runs under textlock, so the function must be side-effect-
|
||||
" free on the buffer. Buffer-mutating branches return a `<Cmd>:call
|
||||
@@ -1112,6 +1383,39 @@ function! nuwiki#commands#smart_return() abort
|
||||
return "\<CR>"
|
||||
endfunction
|
||||
|
||||
" Insert-mode `<S-CR>`: continue the current list item on a new line WITHOUT a
|
||||
" new marker, indented to align under the item's text — vimwiki's "multiline
|
||||
" list item" (`VimwikiReturn 2 2`). Off a list item it's a plain `<CR>`.
|
||||
function! nuwiki#commands#smart_shift_return() abort
|
||||
if pumvisible() | return "\<CR>" | endif
|
||||
let l:line = getline('.')
|
||||
let l:m = matchlist(l:line, '^\(\s*\)\([-*#]\)\s\(.*\)$')
|
||||
if empty(l:m)
|
||||
let l:m2 = matchlist(l:line, '^\(\s*\)\(\d\+\)\([.)]\)\s\(.*\)$')
|
||||
if !empty(l:m2)
|
||||
let l:m = [l:line, l:m2[1], l:m2[2] . l:m2[3], l:m2[4]]
|
||||
endif
|
||||
endif
|
||||
if empty(l:m)
|
||||
return "\<CR>"
|
||||
endif
|
||||
let l:indent = l:m[1]
|
||||
let l:marker = l:m[2]
|
||||
let l:has_cb = l:m[3] =~# '^\[[ xXoO.\-]\]'
|
||||
let l:pad = len(l:marker) + 1 + (l:has_cb ? 4 : 0)
|
||||
let l:auto = &autoindent || &smartindent || &cindent || !empty(&indentexpr)
|
||||
return "\<CR>" . (l:auto ? '' : l:indent) . repeat(' ', l:pad)
|
||||
endfunction
|
||||
|
||||
" :VimwikiReturn / :NuwikiReturn — the command form of the smart `<CR>`
|
||||
" continuation (upstream's mapping-driven `VimwikiReturn`). Enters insert at
|
||||
" end of line and triggers the buffer-local `<CR>` mapping (smart_return),
|
||||
" continuing the list item / table row. Args mirror upstream's mode flags and
|
||||
" are accepted but unused (the continuation is inferred from the line).
|
||||
function! nuwiki#commands#return_cmd(...) abort
|
||||
call feedkeys("A\<CR>", 'm')
|
||||
endfunction
|
||||
|
||||
" Insert-mode `<Tab>` / `<S-Tab>` table navigation — vimwiki parity.
|
||||
" Used as `<expr>` mappings so we can pass `<Tab>` / `<S-Tab>` through
|
||||
" verbatim when the cursor isn't on a table row.
|
||||
@@ -1177,15 +1481,46 @@ function! nuwiki#commands#smart_shift_tab() abort
|
||||
return "\<Cmd>call nuwiki#commands#_table_jump_to_cell(" . line('.') . ", " . l:target_idx . ")\<CR>"
|
||||
endfunction
|
||||
|
||||
" `:VimwikiNormalizeLink` — wrap the word at cursor as `[[word]]`
|
||||
" without following. Pure-VimL.
|
||||
function! nuwiki#commands#normalize_link() abort
|
||||
" `:VimwikiNormalizeLink [1]` — wrap text as `[[…]]` without following.
|
||||
" With a:1 (upstream's visual `1` arg, also the `x`-mode mapping) wrap the
|
||||
" `'<`/`'>` selection; otherwise the word under the cursor. Pure-VimL.
|
||||
function! nuwiki#commands#normalize_link(...) abort
|
||||
let l:visual = a:0 >= 1 && a:1
|
||||
if l:visual
|
||||
call s:wrap_visual_as_wikilink()
|
||||
return
|
||||
endif
|
||||
if s:cursor_inside_wikilink()
|
||||
return
|
||||
endif
|
||||
call s:wrap_cword_as_wikilink()
|
||||
endfunction
|
||||
|
||||
" Wrap the single-line `'<`/`'>` visual selection as `[[selection]]`.
|
||||
function! s:wrap_visual_as_wikilink() abort
|
||||
let l:sp = getpos("'<")
|
||||
let l:ep = getpos("'>")
|
||||
if l:sp[1] <= 0 || l:sp[1] != l:ep[1]
|
||||
return
|
||||
endif
|
||||
let l:line = getline(l:sp[1])
|
||||
let l:sc = l:sp[2]
|
||||
let l:ec = l:ep[2]
|
||||
if l:ec > strlen(l:line)
|
||||
let l:ec = strlen(l:line)
|
||||
endif
|
||||
if l:ec < l:sc
|
||||
return
|
||||
endif
|
||||
let l:sel = strpart(l:line, l:sc - 1, l:ec - l:sc + 1)
|
||||
" Don't double-wrap if the selection is already a bracketed link.
|
||||
if l:sel =~# '^\[\[.*\]\]$'
|
||||
return
|
||||
endif
|
||||
let l:new = strpart(l:line, 0, l:sc - 1) . '[[' . l:sel . ']]' . strpart(l:line, l:ec)
|
||||
call setline(l:sp[1], l:new)
|
||||
endfunction
|
||||
|
||||
function! s:echo_url_from(response, ...) abort
|
||||
let l:result = get(get(a:response, 'response', {}), 'result', '')
|
||||
if type(l:result) == type('') && l:result !=# ''
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
" autoload/nuwiki/complete.vim — command-line completion for the
|
||||
" :Vimwiki*/:Nuwiki* commands (mirrors upstream vimwiki's -complete= specs).
|
||||
"
|
||||
" Pure client-side and synchronous: candidates come from config and the
|
||||
" filesystem, never the LSP, so a `-complete=customlist,{fn}` attribute can
|
||||
" call these directly under vim-lsp, coc, and Neovim alike.
|
||||
"
|
||||
" NOTE on files/RenameFile: upstream's :VimwikiRenameFile takes a new-name arg
|
||||
" with file completion because upstream performs the rename itself. nuwiki
|
||||
" delegates renaming to the LSP rename request (`:LspRename` / coc `rename`),
|
||||
" which drives its own interactive prompt — there is no filename argument to
|
||||
" complete, so no file completer is provided (documented divergence).
|
||||
|
||||
" Page-name completion (VimwikiGoto). Globs every configured wiki root for its
|
||||
" `*<ext>` files and returns their root-relative names with the extension
|
||||
" stripped, filtered by {arglead}. Mirrors upstream complete_links_raw.
|
||||
function! nuwiki#complete#pages(arglead, cmdline, cursorpos) abort
|
||||
let l:names = {}
|
||||
for l:w in nuwiki#commands#wiki_list()
|
||||
let l:root = substitute(get(l:w, 'root', ''), '/\+$', '', '')
|
||||
if l:root ==# '' | continue | endif
|
||||
let l:ext = get(l:w, 'ext', '.wiki')
|
||||
for l:f in glob(l:root . '/**/*' . l:ext, 0, 1)
|
||||
let l:rel = l:f[len(l:root) + 1 :]
|
||||
if l:rel ==# '' | continue | endif
|
||||
let l:rel = l:rel[: -len(l:ext) - 1]
|
||||
let l:names[l:rel] = 1
|
||||
endfor
|
||||
endfor
|
||||
return filter(sort(keys(l:names)), 'stridx(v:val, a:arglead) == 0')
|
||||
endfunction
|
||||
|
||||
" Colour completion (VimwikiColorize) — keys from any configured `color_dic`
|
||||
" (per-wiki `g:nuwiki_wikis[].color_dic` or the scalar `g:nuwiki_color_dic`)
|
||||
" plus colours already used in `color:NAME` spans in the current buffer.
|
||||
function! nuwiki#complete#colors(arglead, cmdline, cursorpos) abort
|
||||
let l:set = {}
|
||||
for l:w in get(g:, 'nuwiki_wikis', [])
|
||||
for l:k in keys(get(l:w, 'color_dic', {}))
|
||||
let l:set[l:k] = 1
|
||||
endfor
|
||||
endfor
|
||||
for l:k in keys(get(g:, 'nuwiki_color_dic', {}))
|
||||
let l:set[l:k] = 1
|
||||
endfor
|
||||
for l:line in getline(1, '$')
|
||||
let l:start = 0
|
||||
while 1
|
||||
let l:m = matchstrpos(l:line, 'color:\s*\zs[^"'';)]\+', l:start)
|
||||
if l:m[1] < 0 | break | endif
|
||||
let l:set[trim(l:m[0])] = 1
|
||||
let l:start = l:m[2]
|
||||
endwhile
|
||||
endfor
|
||||
return filter(sort(keys(l:set)), 'stridx(v:val, a:arglead) == 0')
|
||||
endfunction
|
||||
|
||||
" Tag completion (VimwikiSearchTags / VimwikiGenerateTagLinks). The tag index
|
||||
" lives in the LSP server, which a synchronous completer can't query, so we
|
||||
" harvest `:tag:` tokens by scanning the configured wikis' files directly —
|
||||
" the same source the server indexes. Runs only on <Tab> at the `:` line.
|
||||
function! nuwiki#complete#tags(arglead, cmdline, cursorpos) abort
|
||||
let l:set = {}
|
||||
for l:w in nuwiki#commands#wiki_list()
|
||||
let l:root = substitute(get(l:w, 'root', ''), '/\+$', '', '')
|
||||
if l:root ==# '' | continue | endif
|
||||
let l:ext = get(l:w, 'ext', '.wiki')
|
||||
for l:f in glob(l:root . '/**/*' . l:ext, 0, 1)
|
||||
for l:line in readfile(l:f)
|
||||
" Tags come in colon-delimited runs like `:foo:bar:` (no spaces).
|
||||
" Match a whole run, then split out each tag (adjacent tags share a
|
||||
" colon, so a per-tag scan would miss every other one).
|
||||
let l:start = 0
|
||||
while 1
|
||||
let l:m = matchstrpos(l:line, ':\%([^:[:space:]]\+:\)\+', l:start)
|
||||
if l:m[1] < 0 | break | endif
|
||||
for l:t in split(l:m[0], ':')
|
||||
if l:t !=# '' | let l:set[l:t] = 1 | endif
|
||||
endfor
|
||||
let l:start = l:m[2]
|
||||
endwhile
|
||||
endfor
|
||||
endfor
|
||||
endfor
|
||||
return filter(sort(keys(l:set)), 'stridx(v:val, a:arglead) == 0')
|
||||
endfunction
|
||||
@@ -0,0 +1,103 @@
|
||||
" autoload/nuwiki/config.vim — single source of truth for the configured wiki
|
||||
" list, shared by the LSP payload (lsp.vim) and the buffer-side commands
|
||||
" (commands.vim, diary.vim, complete.vim).
|
||||
"
|
||||
" Resolves wikis from g:nuwiki_wikis (native) or, when that's unset, an upstream
|
||||
" g:vimwiki_list (drop-in compatibility), translating upstream key names and
|
||||
" folding the global scalar defaults (g:vimwiki_* / g:nuwiki_*) into each entry.
|
||||
" Everything downstream — `<Leader>ww`, link resolution, the server payload —
|
||||
" then sees the same wikis.
|
||||
|
||||
" Per-wiki dict keys: upstream `g:vimwiki_list[i]` key -> nuwiki/server key.
|
||||
let s:vimwiki_wiki_map = {
|
||||
\ 'path': 'root',
|
||||
\ 'path_html': 'html_path',
|
||||
\ 'template_path': 'template_path',
|
||||
\ 'template_default': 'template_default',
|
||||
\ 'template_ext': 'template_ext',
|
||||
\ 'css_name': 'css_name',
|
||||
\ 'auto_export': 'auto_export',
|
||||
\ 'auto_toc': 'auto_toc',
|
||||
\ 'syntax': 'syntax',
|
||||
\ 'ext': 'file_extension',
|
||||
\ 'index': 'index',
|
||||
\ 'diary_rel_path': 'diary_rel_path',
|
||||
\ 'diary_index': 'diary_index',
|
||||
\ 'name': 'name',
|
||||
\ }
|
||||
|
||||
" Global scalar settings vimwiki applies to every wiki (via wikilocal
|
||||
" defaults): upstream suffix (`g:vimwiki_<suffix>`) -> nuwiki/server key.
|
||||
let s:scalar_global_map = {
|
||||
\ 'toc_header': 'toc_header',
|
||||
\ 'toc_header_level': 'toc_header_level',
|
||||
\ 'toc_link_format': 'toc_link_format',
|
||||
\ 'links_header': 'links_header',
|
||||
\ 'links_header_level': 'links_header_level',
|
||||
\ 'tags_header': 'tags_header',
|
||||
\ 'tags_header_level': 'tags_header_level',
|
||||
\ 'html_header_numbering': 'html_header_numbering',
|
||||
\ 'html_header_numbering_sym': 'html_header_numbering_sym',
|
||||
\ 'links_space_char': 'links_space_char',
|
||||
\ 'list_margin': 'list_margin',
|
||||
\ 'listsyms': 'listsyms',
|
||||
\ 'listsym_rejected': 'listsym_rejected',
|
||||
\ 'auto_export': 'auto_export',
|
||||
\ 'auto_toc': 'auto_toc',
|
||||
\ 'auto_generate_links': 'auto_generate_links',
|
||||
\ 'auto_generate_tags': 'auto_generate_tags',
|
||||
\ 'auto_diary_index': 'auto_diary_index',
|
||||
\ }
|
||||
|
||||
" Resolve the per-wiki scalar globals: vimwiki values first (compat), then
|
||||
" nuwiki-native `g:nuwiki_<key>` overrides on top.
|
||||
function! nuwiki#config#scalar_globals() abort
|
||||
let l:g = {}
|
||||
for [l:src, l:dst] in items(s:scalar_global_map)
|
||||
if exists('g:vimwiki_' . l:src)
|
||||
let l:g[l:dst] = get(g:, 'vimwiki_' . l:src)
|
||||
endif
|
||||
endfor
|
||||
for l:dst in values(s:scalar_global_map)
|
||||
if exists('g:nuwiki_' . l:dst)
|
||||
let l:g[l:dst] = get(g:, 'nuwiki_' . l:dst)
|
||||
endif
|
||||
endfor
|
||||
return l:g
|
||||
endfunction
|
||||
|
||||
" Translate an upstream g:vimwiki_list into nuwiki's wiki list, folding the
|
||||
" given global scalars into each entry. Returns [] when unset/malformed.
|
||||
function! s:wikis_from_vimwiki(globals) abort
|
||||
if !exists('g:vimwiki_list') || type(g:vimwiki_list) != type([]) || empty(g:vimwiki_list)
|
||||
return []
|
||||
endif
|
||||
let l:wikis = []
|
||||
for l:vw in g:vimwiki_list
|
||||
if type(l:vw) != type({}) | continue | endif
|
||||
let l:w = copy(a:globals)
|
||||
for [l:src, l:dst] in items(s:vimwiki_wiki_map)
|
||||
if has_key(l:vw, l:src)
|
||||
let l:w[l:dst] = l:vw[l:src]
|
||||
endif
|
||||
endfor
|
||||
if has_key(l:w, 'root')
|
||||
call add(l:wikis, l:w)
|
||||
endif
|
||||
endfor
|
||||
return l:wikis
|
||||
endfunction
|
||||
|
||||
" The configured wikis as normalized dicts (nuwiki/server key names), with the
|
||||
" global scalar defaults folded in (per-wiki value wins). Priority:
|
||||
" 1. g:nuwiki_wikis (native)
|
||||
" 2. g:vimwiki_list (upstream drop-in)
|
||||
" Returns [] when neither is set — callers fall back to the single-wiki
|
||||
" shorthand (g:nuwiki_wiki_root).
|
||||
function! nuwiki#config#wikis() abort
|
||||
let l:globals = nuwiki#config#scalar_globals()
|
||||
if exists('g:nuwiki_wikis') && !empty(g:nuwiki_wikis)
|
||||
return map(copy(g:nuwiki_wikis), {_, w -> extend(copy(l:globals), w, 'force')})
|
||||
endif
|
||||
return s:wikis_from_vimwiki(l:globals)
|
||||
endfunction
|
||||
+35
-21
@@ -3,6 +3,33 @@
|
||||
|
||||
" ===== Calendar-Vim Integration =====
|
||||
|
||||
" The wiki index (0-based) of the most recently entered wiki buffer. The
|
||||
" calendar shows / creates diary entries for this wiki, so opening the calendar
|
||||
" while editing `ifood_wiki` diaries into ifood_wiki — not always the first
|
||||
" wiki. Updated by the BufEnter autocmd in plugin/nuwiki.vim; defaults to 0.
|
||||
" (The LSP-driven diary commands already follow the current buffer via its URI;
|
||||
" this mirrors that for the VimL calendar-vim callbacks.)
|
||||
let s:current_wiki_idx = 0
|
||||
|
||||
" Record the wiki that owns `file` as the current one. Called on entering a
|
||||
" wiki buffer. No-op when the file is under no configured wiki.
|
||||
function! nuwiki#diary#track_wiki(file) abort
|
||||
let l:file = fnamemodify(a:file, ':p')
|
||||
if empty(l:file)
|
||||
return
|
||||
endif
|
||||
let l:n = 0
|
||||
for l:w in nuwiki#commands#wiki_list()
|
||||
let l:root = fnamemodify(expand(l:w.root), ':p')
|
||||
if l:root[len(l:root) - 1:] !=# '/' | let l:root .= '/' | endif
|
||||
if l:file[: len(l:root) - 1] ==# l:root
|
||||
let s:current_wiki_idx = l:n
|
||||
return
|
||||
endif
|
||||
let l:n += 1
|
||||
endfor
|
||||
endfunction
|
||||
|
||||
" Function: nuwiki#diary#calendar_action(day, month, year, week, dir)
|
||||
" Called by calendar-vim when user presses Enter on a date.
|
||||
" day/month/year are numeric integers; dir is 'V' for a vertical split (any
|
||||
@@ -12,8 +39,8 @@ function! nuwiki#diary#calendar_action(day, month, year, week, dir) abort
|
||||
" Build YYYY-MM-DD from the parts passed by calendar-vim
|
||||
let l:date_str = printf('%04d-%02d-%02d', a:year, a:month, a:day)
|
||||
|
||||
" Get wiki configuration for the first wiki (index 0)
|
||||
let l:c = s:wiki_cfg(0)
|
||||
" Wiki of the buffer the calendar was opened from (falls back to the first).
|
||||
let l:c = s:wiki_cfg(s:current_wiki_idx)
|
||||
|
||||
" Construct file path: {root}/{diary_rel}/{YYYY-MM-DD}.{ext}
|
||||
let l:file_path = l:c.root . '/' . l:c.diary_rel . '/' . l:date_str . l:c.ext
|
||||
@@ -62,8 +89,8 @@ function! nuwiki#diary#calendar_sign(day, month, year) abort
|
||||
" Build YYYY-MM-DD from the parts passed by calendar-vim
|
||||
let l:date_str = printf('%04d-%02d-%02d', a:year, a:month, a:day)
|
||||
|
||||
" Get wiki configuration for the first wiki (index 0)
|
||||
let l:c = s:wiki_cfg(0)
|
||||
" Wiki of the buffer the calendar was opened from (falls back to the first).
|
||||
let l:c = s:wiki_cfg(s:current_wiki_idx)
|
||||
|
||||
" Construct expected file path
|
||||
let l:file_path = l:c.root . '/' . l:c.diary_rel . '/' . l:date_str . l:c.ext
|
||||
@@ -75,22 +102,9 @@ function! nuwiki#diary#calendar_sign(day, month, year) abort
|
||||
return ''
|
||||
endfunction
|
||||
|
||||
" Helper: get wiki configuration for wiki n (0-based).
|
||||
" Mirrors the same logic used by nuwiki#commands#open_diary_path etc.
|
||||
" Helper: get wiki configuration for wiki n (0-based). Resolves through
|
||||
" nuwiki#config#wikis() so g:nuwiki_wikis AND an upstream g:vimwiki_list both
|
||||
" work, falling back to the scalar g:nuwiki_* single-wiki shorthand.
|
||||
function! s:wiki_cfg(n) abort
|
||||
let l:w = {}
|
||||
if exists('g:nuwiki_wikis') && len(g:nuwiki_wikis) > a:n
|
||||
let l:w = g:nuwiki_wikis[a:n]
|
||||
endif
|
||||
let l:root = expand(get(l:w, 'root',
|
||||
\ get(g:, 'nuwiki_wiki_root', '~/vimwiki')))
|
||||
let l:ext = get(l:w, 'file_extension',
|
||||
\ get(g:, 'nuwiki_file_extension', '.wiki'))
|
||||
if l:ext[0] !=# '.' | let l:ext = '.' . l:ext | endif
|
||||
let l:idx = get(l:w, 'index', 'index')
|
||||
let l:diary_rel = get(l:w, 'diary_rel_path',
|
||||
\ get(g:, 'nuwiki_diary_rel_path', 'diary'))
|
||||
let l:diary_idx = get(l:w, 'diary_index', 'diary')
|
||||
return { 'root': l:root, 'ext': l:ext, 'idx': l:idx,
|
||||
\ 'diary_rel': l:diary_rel, 'diary_idx': l:diary_idx }
|
||||
return nuwiki#util#wiki_cfg(a:n)
|
||||
endfunction
|
||||
|
||||
@@ -13,15 +13,7 @@
|
||||
" end of buffer).
|
||||
|
||||
function! s:heading_level(line) abort
|
||||
let l:lead = matchstr(a:line, '^\s*\zs=\+\ze\s')
|
||||
if empty(l:lead) | return 0 | endif
|
||||
let l:lvl = strlen(l:lead)
|
||||
if l:lvl > 6 | return 0 | endif
|
||||
let l:trail = matchstr(a:line, '\s\zs=\+\ze\s*$')
|
||||
if empty(l:trail) || strlen(l:trail) != l:lvl
|
||||
return 0
|
||||
endif
|
||||
return l:lvl
|
||||
return nuwiki#util#heading_level(a:line)
|
||||
endfunction
|
||||
|
||||
function! nuwiki#folding#expr() abort
|
||||
|
||||
+71
-22
@@ -45,22 +45,7 @@ function! nuwiki#lsp#start() abort
|
||||
endif
|
||||
|
||||
if exists(':CocConfig') == 2 || exists('*coc#config')
|
||||
" coc.nvim — coc reads its server list from coc-settings.json.
|
||||
" We can't inject it dynamically without owning the file, so just point
|
||||
" the user at the snippet. `initializationOptions` carries wiki_root etc.
|
||||
" to the server exactly like the vim-lsp registration above — without it
|
||||
" the server resolves links against its default root, so existing pages
|
||||
" look broken and follow-to-create lands in the wrong directory.
|
||||
echohl WarningMsg
|
||||
echom 'nuwiki: coc.nvim detected. Add this to your coc-settings.json:'
|
||||
echom ' "languageserver": {'
|
||||
echom ' "nuwiki": {'
|
||||
echom ' "command": "' . l:bin . '",'
|
||||
echom ' "filetypes": ["vimwiki"],'
|
||||
echom ' "initializationOptions": ' . json_encode(s:settings())
|
||||
echom ' }'
|
||||
echom ' }'
|
||||
echohl None
|
||||
call s:register_with_coc(l:bin)
|
||||
return
|
||||
endif
|
||||
|
||||
@@ -71,6 +56,66 @@ function! nuwiki#lsp#start() abort
|
||||
echohl None
|
||||
endfunction
|
||||
|
||||
" ===== coc.nvim integration =====
|
||||
"
|
||||
" Register the nuwiki language server with coc *programmatically* via
|
||||
" coc#config, so users don't have to hand-maintain a coc-settings.json entry
|
||||
" per wiki. The config (wiki roots, per-wiki keys, the vimwiki/nuwiki global
|
||||
" shorthand) comes from s:settings() — the same payload the vim-lsp path sends.
|
||||
" coc reacts to the `languageserver` config change, registers the server, and
|
||||
" starts it for the open vimwiki buffer.
|
||||
"
|
||||
" Opt out with `let g:nuwiki_no_coc_register = 1` to manage the entry in
|
||||
" coc-settings.json yourself (we then just print the snippet).
|
||||
function! s:register_with_coc(bin) abort
|
||||
if get(g:, 'nuwiki_no_coc_register', 0)
|
||||
call s:print_coc_snippet(a:bin)
|
||||
return
|
||||
endif
|
||||
let s:coc_bin = a:bin
|
||||
if get(g:, 'coc_service_initialized', 0)
|
||||
call s:coc_register()
|
||||
else
|
||||
" coc hasn't finished starting its node service yet — defer until it has,
|
||||
" otherwise the updateConfig notification is sent to a service that isn't
|
||||
" listening.
|
||||
augroup nuwiki_coc_register
|
||||
autocmd!
|
||||
autocmd User CocNvimInit ++once call s:coc_register()
|
||||
augroup END
|
||||
endif
|
||||
endfunction
|
||||
|
||||
function! s:coc_register() abort
|
||||
let l:cfg = s:settings()
|
||||
" Call coc#config directly — `exists('*coc#config')` can't be trusted because
|
||||
" it doesn't trigger autoloading, so we let the call autoload coc.vim and
|
||||
" fall back to the manual snippet only if coc genuinely isn't there.
|
||||
try
|
||||
call coc#config('languageserver.nuwiki', {
|
||||
\ 'command': s:coc_bin,
|
||||
\ 'filetypes': ['vimwiki'],
|
||||
\ 'initializationOptions': l:cfg,
|
||||
\ 'settings': { 'nuwiki': l:cfg },
|
||||
\ })
|
||||
catch /^Vim\%((\a\+)\)\=:E11[78]/
|
||||
call s:print_coc_snippet(s:coc_bin)
|
||||
endtry
|
||||
endfunction
|
||||
|
||||
function! s:print_coc_snippet(bin) abort
|
||||
echohl WarningMsg
|
||||
echom 'nuwiki: coc.nvim detected. Add this to your coc-settings.json:'
|
||||
echom ' "languageserver": {'
|
||||
echom ' "nuwiki": {'
|
||||
echom ' "command": "' . a:bin . '",'
|
||||
echom ' "filetypes": ["vimwiki"],'
|
||||
echom ' "initializationOptions": ' . json_encode(s:settings())
|
||||
echom ' }'
|
||||
echom ' }'
|
||||
echohl None
|
||||
endfunction
|
||||
|
||||
function! s:bin_path() abort
|
||||
if exists('g:nuwiki_binary_path') && filereadable(g:nuwiki_binary_path)
|
||||
return g:nuwiki_binary_path
|
||||
@@ -89,12 +134,16 @@ function! s:settings() abort
|
||||
\ 'link_severity': get(g:, 'nuwiki_link_severity', 'warn'),
|
||||
\ },
|
||||
\ }
|
||||
" Include the per-wiki list when configured via g:nuwiki_wikis so the
|
||||
" server knows each wiki's root, diary path, etc. Without this it only
|
||||
" sees wiki_root and resolves links relative to that directory instead
|
||||
" of the individual wiki's root.
|
||||
if exists('g:nuwiki_wikis') && !empty(g:nuwiki_wikis)
|
||||
let l:cfg['wikis'] = g:nuwiki_wikis
|
||||
" Wiki list comes from the shared resolver (nuwiki#config#wikis), so the
|
||||
" server payload and the buffer-side commands agree on which wikis exist —
|
||||
" native g:nuwiki_wikis or an upstream g:vimwiki_list, globals already folded.
|
||||
let l:wikis = nuwiki#config#wikis()
|
||||
if !empty(l:wikis)
|
||||
let l:cfg['wikis'] = l:wikis
|
||||
else
|
||||
" Single-wiki shorthand: fold the global scalars into the top-level payload
|
||||
" so the server's single-wiki desugaring honours them.
|
||||
call extend(l:cfg, nuwiki#config#scalar_globals(), 'force')
|
||||
endif
|
||||
return l:cfg
|
||||
endfunction
|
||||
|
||||
@@ -24,15 +24,7 @@
|
||||
" ===== Heading helpers =====
|
||||
|
||||
function! s:heading_level(line) abort
|
||||
let l:lead = matchstr(a:line, '^\s*\zs=\+\ze\s')
|
||||
if empty(l:lead) | return 0 | endif
|
||||
let l:lvl = strlen(l:lead)
|
||||
if l:lvl > 6 | return 0 | endif
|
||||
let l:trail = matchstr(a:line, '\s\zs=\+\ze\s*$')
|
||||
if empty(l:trail) || strlen(l:trail) != l:lvl
|
||||
return 0
|
||||
endif
|
||||
return l:lvl
|
||||
return nuwiki#util#heading_level(a:line)
|
||||
endfunction
|
||||
|
||||
" Returns `[start, end]` line numbers, or `[]` if no heading found.
|
||||
@@ -130,7 +122,7 @@ endfunction
|
||||
" ===== Table cell / column =====
|
||||
|
||||
function! s:is_table_row(line) abort
|
||||
return a:line =~# '^\s*|.*|\s*$'
|
||||
return nuwiki#util#is_table_row(a:line)
|
||||
endfunction
|
||||
|
||||
" Returns a list of `[start_col, end_col]` byte positions (1-based,
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
" autoload/nuwiki/util.vim — small shared helpers used across the VimL client
|
||||
" layers (commands, folding, textobjects, diary). Keeps the formerly
|
||||
" copy-pasted heading / table-row / wiki-config logic in one place. Mirrors
|
||||
" lua/nuwiki/util.lua so both clients behave identically.
|
||||
|
||||
" Heading level of `line` (1-6), or 0 when it isn't a heading. Mirrors the
|
||||
" server lexer: balanced leading/trailing runs of `=` around a non-empty
|
||||
" title, capped at level 6. Accepts both the spaced (`== H ==`) and spaceless
|
||||
" (`==H==`) forms, matching vimwiki.
|
||||
function! nuwiki#util#heading_level(line) abort
|
||||
let l:lead = matchstr(a:line, '^\s*\zs=\+')
|
||||
if empty(l:lead)
|
||||
return 0
|
||||
endif
|
||||
let l:lvl = strlen(l:lead)
|
||||
if l:lvl > 6
|
||||
return 0
|
||||
endif
|
||||
" Same-length trailing run of `=` at end of line (after optional trailing ws).
|
||||
let l:trail = matchstr(a:line, '=\+\ze\s*$')
|
||||
if empty(l:trail) || strlen(l:trail) != l:lvl
|
||||
return 0
|
||||
endif
|
||||
" Non-empty title between the runs (rules out marker-only lines like
|
||||
" `======`). `.\{-}` is non-greedy so the `=\+` runs keep their length.
|
||||
let l:body = matchstr(a:line, '^\s*=\+\zs.\{-}\ze=\+\s*$')
|
||||
if l:body =~# '^\s*$'
|
||||
return 0
|
||||
endif
|
||||
return l:lvl
|
||||
endfunction
|
||||
|
||||
" True when `line` looks like a table row (`| … |`).
|
||||
function! nuwiki#util#is_table_row(line) abort
|
||||
return a:line =~# '^\s*|.*|\s*$'
|
||||
endfunction
|
||||
|
||||
" Resolve the config dict for wiki #n (0-based), falling back to the scalar
|
||||
" g:nuwiki_* globals. Returns root/ext/idx/diary_rel/diary_idx/space.
|
||||
function! nuwiki#util#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:root = expand(get(l:w, 'root',
|
||||
\ get(g:, 'nuwiki_wiki_root', '~/vimwiki')))
|
||||
let l:ext = get(l:w, 'file_extension',
|
||||
\ get(g:, 'nuwiki_file_extension', '.wiki'))
|
||||
if l:ext[0] !=# '.' | let l:ext = '.' . l:ext | endif
|
||||
let l:idx = get(l:w, 'index', 'index')
|
||||
let l:diary_rel = get(l:w, 'diary_rel_path',
|
||||
\ get(g:, 'nuwiki_diary_rel_path', 'diary'))
|
||||
let l:diary_idx = get(l:w, 'diary_index', 'diary')
|
||||
let l:space = get(l:w, 'links_space_char',
|
||||
\ get(g:, 'nuwiki_links_space_char', ' '))
|
||||
return { 'root': l:root, 'ext': l:ext, 'idx': l:idx,
|
||||
\ 'diary_rel': l:diary_rel, 'diary_idx': l:diary_idx, 'space': l:space }
|
||||
endfunction
|
||||
@@ -6,6 +6,30 @@
|
||||
" Values are read from nuwiki's own config variables so users don't have
|
||||
" to duplicate settings.
|
||||
|
||||
" Resolve which wiki a `get_wikilocal` call refers to, mirroring upstream:
|
||||
" - an explicit numeric second argument selects that wiki (0-based);
|
||||
" - otherwise the wiki owning the current buffer is used
|
||||
" (upstream's `g:vimwiki_current_idx`);
|
||||
" - failing that, the first/shorthand wiki (index 0).
|
||||
function! s:resolve_index(...) abort
|
||||
if a:0 >= 1 && type(a:1) == type(0)
|
||||
return a:1
|
||||
endif
|
||||
let l:file = expand('%:p')
|
||||
if !empty(l:file)
|
||||
let l:n = 0
|
||||
for l:w in nuwiki#commands#wiki_list()
|
||||
let l:wroot = fnamemodify(expand(l:w.root), ':p')
|
||||
if l:wroot[len(l:wroot) - 1:] !=# '/' | let l:wroot .= '/' | endif
|
||||
if l:file[: len(l:wroot) - 1] ==# l:wroot
|
||||
return l:n
|
||||
endif
|
||||
let l:n += 1
|
||||
endfor
|
||||
endif
|
||||
return 0
|
||||
endfunction
|
||||
|
||||
" Per-wiki local config values.
|
||||
"
|
||||
" Supported keys:
|
||||
@@ -13,21 +37,38 @@
|
||||
" is_temporary_wiki — always 0 for a configured wiki
|
||||
" ext / extension — file extension (default '.wiki')
|
||||
" syntax — syntax name (default 'vimwiki')
|
||||
"
|
||||
" The optional second argument is the wiki index (upstream's signature);
|
||||
" when omitted, the wiki owning the current buffer is used. Values are
|
||||
" pulled from the matching `g:nuwiki_wikis` entry, falling back to the
|
||||
" scalar `g:nuwiki_*` globals for keys that entry doesn't set.
|
||||
function! vimwiki#vars#get_wikilocal(key, ...) abort
|
||||
if a:key ==# 'is_temporary_wiki'
|
||||
return 0
|
||||
endif
|
||||
|
||||
let l:idx = call('s:resolve_index', a:000)
|
||||
let l:wikis = nuwiki#commands#wiki_list()
|
||||
if l:idx < 0 || l:idx >= len(l:wikis)
|
||||
let l:idx = 0
|
||||
endif
|
||||
let l:w = l:wikis[l:idx]
|
||||
|
||||
if a:key ==# 'path'
|
||||
let l:root = expand(get(g:, 'nuwiki_wiki_root', '~/vimwiki'))
|
||||
let l:root = expand(l:w.root)
|
||||
" Ensure trailing slash to match what the original vimwiki returns.
|
||||
return l:root =~# '/$' ? l:root : l:root . '/'
|
||||
|
||||
elseif a:key ==# 'is_temporary_wiki'
|
||||
return 0
|
||||
|
||||
elseif a:key ==# 'ext' || a:key ==# 'extension'
|
||||
let l:ext = get(g:, 'nuwiki_file_extension', '.wiki')
|
||||
return l:ext[0] ==# '.' ? l:ext : '.' . l:ext
|
||||
" wiki_list() already normalises the leading dot.
|
||||
return l:w.ext
|
||||
|
||||
elseif a:key ==# 'syntax'
|
||||
return get(g:, 'nuwiki_syntax', 'vimwiki')
|
||||
" wiki_list() doesn't carry `syntax`; read the per-wiki entry, then the
|
||||
" global default.
|
||||
let l:cfg = (exists('g:nuwiki_wikis') && len(g:nuwiki_wikis) > l:idx)
|
||||
\ ? g:nuwiki_wikis[l:idx] : {}
|
||||
return get(l:cfg, 'syntax', get(g:, 'nuwiki_syntax', 'vimwiki'))
|
||||
|
||||
endif
|
||||
return ''
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
[package]
|
||||
name = "nuwiki-core"
|
||||
description = "Core parser, AST, and renderer for nuwiki — editor-independent."
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
homepage.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
@@ -1,192 +0,0 @@
|
||||
//! Block-level AST nodes and their supporting types.
|
||||
|
||||
use super::inline::InlineNode;
|
||||
use super::span::Span;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum BlockNode {
|
||||
Heading(HeadingNode),
|
||||
Paragraph(ParagraphNode),
|
||||
HorizontalRule(HorizontalRuleNode),
|
||||
Blockquote(BlockquoteNode),
|
||||
Preformatted(PreformattedNode),
|
||||
MathBlock(MathBlockNode),
|
||||
List(ListNode),
|
||||
DefinitionList(DefinitionListNode),
|
||||
Table(TableNode),
|
||||
Comment(CommentNode),
|
||||
/// Vimwiki tag line — `:tag1:tag2:`. The placement
|
||||
/// rule (file / heading / standalone) is captured in `TagScope` so
|
||||
/// LSP handlers and renderers can treat the three differently.
|
||||
Tag(TagNode),
|
||||
Error(ErrorNode),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct HeadingNode {
|
||||
pub span: Span,
|
||||
/// 1..=6 — invariant enforced by the parser, not the type.
|
||||
pub level: u8,
|
||||
pub children: Vec<InlineNode>,
|
||||
pub centered: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ParagraphNode {
|
||||
pub span: Span,
|
||||
pub children: Vec<InlineNode>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct HorizontalRuleNode {
|
||||
pub span: Span,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct BlockquoteNode {
|
||||
pub span: Span,
|
||||
pub children: Vec<BlockNode>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PreformattedNode {
|
||||
pub span: Span,
|
||||
pub content: String,
|
||||
pub language: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MathBlockNode {
|
||||
pub span: Span,
|
||||
pub content: String,
|
||||
pub environment: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ListNode {
|
||||
pub span: Span,
|
||||
pub ordered: bool,
|
||||
pub symbol: ListSymbol,
|
||||
pub items: Vec<ListItemNode>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ListItemNode {
|
||||
pub span: Span,
|
||||
pub symbol: ListSymbol,
|
||||
pub level: usize,
|
||||
pub checkbox: Option<CheckboxState>,
|
||||
pub children: Vec<InlineNode>,
|
||||
pub sublist: Option<ListNode>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum ListSymbol {
|
||||
Dash,
|
||||
Star,
|
||||
Hash,
|
||||
Numeric,
|
||||
NumericParen,
|
||||
AlphaParen,
|
||||
AlphaUpperParen,
|
||||
RomanParen,
|
||||
RomanUpperParen,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum CheckboxState {
|
||||
Empty,
|
||||
Quarter,
|
||||
Half,
|
||||
ThreeQuarters,
|
||||
Done,
|
||||
Rejected,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DefinitionListNode {
|
||||
pub span: Span,
|
||||
pub items: Vec<DefinitionItemNode>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DefinitionItemNode {
|
||||
pub span: Span,
|
||||
pub term: Option<Vec<InlineNode>>,
|
||||
pub definitions: Vec<Vec<InlineNode>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TableAlign {
|
||||
Default,
|
||||
Left,
|
||||
Right,
|
||||
Center,
|
||||
}
|
||||
|
||||
impl Default for TableAlign {
|
||||
fn default() -> Self {
|
||||
Self::Default
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TableNode {
|
||||
pub span: Span,
|
||||
pub rows: Vec<TableRowNode>,
|
||||
pub has_header: bool,
|
||||
/// One entry per column, taken from a Markdown-style header
|
||||
/// separator row (`|:--|--:|:--:|`). Empty when no alignment was
|
||||
/// specified; cells past the end inherit `Default`.
|
||||
pub alignments: Vec<TableAlign>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TableRowNode {
|
||||
pub span: Span,
|
||||
pub cells: Vec<TableCellNode>,
|
||||
pub is_header: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TableCellNode {
|
||||
pub span: Span,
|
||||
pub children: Vec<InlineNode>,
|
||||
pub col_span: bool,
|
||||
pub row_span: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CommentNode {
|
||||
pub span: Span,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
/// Where a `TagNode` falls on the page, matching vimwiki's placement rules.
|
||||
///
|
||||
/// - `File` — line 0 or 1 of the document; tags the entire page.
|
||||
/// - `Heading(i)` — within two lines below the i-th `HeadingNode` in the
|
||||
/// document's `children`; tags that heading.
|
||||
/// - `Standalone` — anywhere else; the tag is its own anchor on the source
|
||||
/// line.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum TagScope {
|
||||
File,
|
||||
Heading(usize),
|
||||
Standalone,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TagNode {
|
||||
pub span: Span,
|
||||
pub tags: Vec<String>,
|
||||
pub scope: TagScope,
|
||||
}
|
||||
|
||||
/// Resilient parse-failure node — emitted instead of aborting the document.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ErrorNode {
|
||||
pub span: Span,
|
||||
pub raw: String,
|
||||
pub message: String,
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
//! Inline AST nodes.
|
||||
//!
|
||||
//! The link-related variants (`WikiLink`, `ExternalLink`,
|
||||
//! `Transclusion`, `RawUrl`) wrap structs defined in `super::link`.
|
||||
|
||||
use super::link::{ExternalLinkNode, RawUrlNode, TransclusionNode, WikiLinkNode};
|
||||
use super::span::Span;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum Keyword {
|
||||
Todo,
|
||||
Done,
|
||||
Started,
|
||||
Fixme,
|
||||
Fixed,
|
||||
Xxx,
|
||||
Stopped,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum InlineNode {
|
||||
Text(TextNode),
|
||||
Bold(BoldNode),
|
||||
Italic(ItalicNode),
|
||||
BoldItalic(BoldItalicNode),
|
||||
Strikethrough(StrikethroughNode),
|
||||
Code(CodeNode),
|
||||
Superscript(SuperscriptNode),
|
||||
Subscript(SubscriptNode),
|
||||
MathInline(MathInlineNode),
|
||||
Keyword(KeywordNode),
|
||||
Color(ColorNode),
|
||||
WikiLink(WikiLinkNode),
|
||||
ExternalLink(ExternalLinkNode),
|
||||
Transclusion(TransclusionNode),
|
||||
RawUrl(RawUrlNode),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TextNode {
|
||||
pub span: Span,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct BoldNode {
|
||||
pub span: Span,
|
||||
pub children: Vec<InlineNode>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ItalicNode {
|
||||
pub span: Span,
|
||||
pub children: Vec<InlineNode>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct BoldItalicNode {
|
||||
pub span: Span,
|
||||
pub children: Vec<InlineNode>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct StrikethroughNode {
|
||||
pub span: Span,
|
||||
pub children: Vec<InlineNode>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CodeNode {
|
||||
pub span: Span,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SuperscriptNode {
|
||||
pub span: Span,
|
||||
pub children: Vec<InlineNode>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SubscriptNode {
|
||||
pub span: Span,
|
||||
pub children: Vec<InlineNode>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MathInlineNode {
|
||||
pub span: Span,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct KeywordNode {
|
||||
pub span: Span,
|
||||
pub keyword: Keyword,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ColorNode {
|
||||
pub span: Span,
|
||||
pub color: String,
|
||||
pub children: Vec<InlineNode>,
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
//! Link-related AST nodes and supporting types.
|
||||
//!
|
||||
//! All nodes here are inline (`InlineNode` variants);
|
||||
//! they live in their own module because the link model has enough surface
|
||||
//! area (kinds, anchors, interwiki indirection) to warrant separation.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::inline::InlineNode;
|
||||
use super::span::Span;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum LinkKind {
|
||||
Wiki,
|
||||
Interwiki,
|
||||
Diary,
|
||||
File,
|
||||
Local,
|
||||
Raw,
|
||||
AnchorOnly,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)]
|
||||
pub struct LinkTarget {
|
||||
pub kind: LinkKind,
|
||||
pub path: Option<String>,
|
||||
pub wiki_index: Option<usize>,
|
||||
pub wiki_name: Option<String>,
|
||||
pub anchor: Option<String>,
|
||||
pub is_absolute: bool,
|
||||
pub is_directory: bool,
|
||||
}
|
||||
|
||||
impl Default for LinkKind {
|
||||
fn default() -> Self {
|
||||
Self::Wiki
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct WikiLinkNode {
|
||||
pub span: Span,
|
||||
pub target: LinkTarget,
|
||||
pub description: Option<Vec<InlineNode>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ExternalLinkNode {
|
||||
pub span: Span,
|
||||
pub url: String,
|
||||
pub description: Option<Vec<InlineNode>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TransclusionNode {
|
||||
pub span: Span,
|
||||
pub url: String,
|
||||
pub alt: Option<String>,
|
||||
pub attrs: HashMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RawUrlNode {
|
||||
pub span: Span,
|
||||
pub url: String,
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
//! AST types for nuwiki documents.
|
||||
//!
|
||||
//! Types here are syntax-agnostic — both vimwiki and (eventually) markdown
|
||||
//! produce the same node shapes.
|
||||
|
||||
pub mod block;
|
||||
pub mod inline;
|
||||
pub mod link;
|
||||
pub mod span;
|
||||
pub mod visit;
|
||||
|
||||
pub use block::{
|
||||
BlockNode, BlockquoteNode, CheckboxState, CommentNode, DefinitionItemNode, DefinitionListNode,
|
||||
ErrorNode, HeadingNode, HorizontalRuleNode, ListItemNode, ListNode, ListSymbol, MathBlockNode,
|
||||
ParagraphNode, PreformattedNode, TableAlign, TableCellNode, TableNode, TableRowNode, TagNode,
|
||||
TagScope,
|
||||
};
|
||||
pub use inline::{
|
||||
BoldItalicNode, BoldNode, CodeNode, ColorNode, InlineNode, ItalicNode, Keyword, KeywordNode,
|
||||
MathInlineNode, StrikethroughNode, SubscriptNode, SuperscriptNode, TextNode,
|
||||
};
|
||||
pub use link::{
|
||||
ExternalLinkNode, LinkKind, LinkTarget, RawUrlNode, TransclusionNode, WikiLinkNode,
|
||||
};
|
||||
pub use span::{Position, Span};
|
||||
pub use visit::{
|
||||
walk_block, walk_definition_item, walk_document, walk_inline, walk_list, walk_list_item,
|
||||
walk_table, walk_table_row, Visitor,
|
||||
};
|
||||
|
||||
/// Root of an AST.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct DocumentNode {
|
||||
pub span: Span,
|
||||
pub children: Vec<BlockNode>,
|
||||
pub metadata: PageMetadata,
|
||||
}
|
||||
|
||||
/// Document-level placeholders parsed from `%title` / `%nohtml` / `%template`
|
||||
/// / `%date` directives, plus the file-level tag accumulator.
|
||||
///
|
||||
/// New fields are added at the bottom; consumers should construct with
|
||||
/// `..Default::default()` to stay forward-compatible.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct PageMetadata {
|
||||
pub title: Option<String>,
|
||||
pub nohtml: bool,
|
||||
pub template: Option<String>,
|
||||
pub date: Option<String>,
|
||||
/// File-scope tags parsed by the tag lexer. Convenience
|
||||
/// projection of the `BlockNode::Tag` nodes whose scope is `File`.
|
||||
pub tags: Vec<String>,
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
//! Source positions and spans carried on every AST node.
|
||||
//!
|
||||
//! Positions are 0-indexed; `column` is a byte offset
|
||||
//! within a line; `offset` is a byte offset from the start of the document.
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub struct Position {
|
||||
pub line: u32,
|
||||
pub column: u32,
|
||||
pub offset: usize,
|
||||
}
|
||||
|
||||
impl Position {
|
||||
pub const fn new(line: u32, column: u32, offset: usize) -> Self {
|
||||
Self {
|
||||
line,
|
||||
column,
|
||||
offset,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
|
||||
pub struct Span {
|
||||
pub start: Position,
|
||||
pub end: Position,
|
||||
}
|
||||
|
||||
impl Span {
|
||||
pub const fn new(start: Position, end: Position) -> Self {
|
||||
Self { start, end }
|
||||
}
|
||||
}
|
||||
@@ -1,228 +0,0 @@
|
||||
//! Open-recursion AST visitor.
|
||||
//!
|
||||
//! Each `visit_*` method has a default body that calls the corresponding
|
||||
//! `walk_*` free function, which in turn dispatches back into the trait.
|
||||
//! Override any method to short-circuit or augment traversal — call the
|
||||
//! matching `walk_*` from your override to keep descending.
|
||||
//!
|
||||
//! Pattern follows `rustc`'s and `syn`'s visitors.
|
||||
//!
|
||||
//! Read-only for now. A `VisitorMut` flavor can be added when transformations
|
||||
//! become relevant.
|
||||
|
||||
use super::block::{
|
||||
BlockNode, BlockquoteNode, CommentNode, DefinitionItemNode, DefinitionListNode, ErrorNode,
|
||||
HeadingNode, HorizontalRuleNode, ListItemNode, ListNode, MathBlockNode, ParagraphNode,
|
||||
PreformattedNode, TableCellNode, TableNode, TableRowNode, TagNode,
|
||||
};
|
||||
use super::inline::{
|
||||
BoldItalicNode, BoldNode, CodeNode, ColorNode, InlineNode, ItalicNode, KeywordNode,
|
||||
MathInlineNode, StrikethroughNode, SubscriptNode, SuperscriptNode, TextNode,
|
||||
};
|
||||
use super::link::{ExternalLinkNode, RawUrlNode, TransclusionNode, WikiLinkNode};
|
||||
use super::DocumentNode;
|
||||
|
||||
pub trait Visitor {
|
||||
// Top level
|
||||
fn visit_document(&mut self, node: &DocumentNode) {
|
||||
walk_document(self, node);
|
||||
}
|
||||
|
||||
// Block dispatch
|
||||
fn visit_block(&mut self, node: &BlockNode) {
|
||||
walk_block(self, node);
|
||||
}
|
||||
|
||||
// Inline dispatch
|
||||
fn visit_inline(&mut self, node: &InlineNode) {
|
||||
walk_inline(self, node);
|
||||
}
|
||||
|
||||
// Block leaves and recursive block nodes
|
||||
fn visit_heading(&mut self, node: &HeadingNode) {
|
||||
for child in &node.children {
|
||||
self.visit_inline(child);
|
||||
}
|
||||
}
|
||||
fn visit_paragraph(&mut self, node: &ParagraphNode) {
|
||||
for child in &node.children {
|
||||
self.visit_inline(child);
|
||||
}
|
||||
}
|
||||
fn visit_horizontal_rule(&mut self, _node: &HorizontalRuleNode) {}
|
||||
fn visit_blockquote(&mut self, node: &BlockquoteNode) {
|
||||
for child in &node.children {
|
||||
self.visit_block(child);
|
||||
}
|
||||
}
|
||||
fn visit_preformatted(&mut self, _node: &PreformattedNode) {}
|
||||
fn visit_math_block(&mut self, _node: &MathBlockNode) {}
|
||||
fn visit_list(&mut self, node: &ListNode) {
|
||||
walk_list(self, node);
|
||||
}
|
||||
fn visit_list_item(&mut self, node: &ListItemNode) {
|
||||
walk_list_item(self, node);
|
||||
}
|
||||
fn visit_definition_list(&mut self, node: &DefinitionListNode) {
|
||||
for item in &node.items {
|
||||
self.visit_definition_item(item);
|
||||
}
|
||||
}
|
||||
fn visit_definition_item(&mut self, node: &DefinitionItemNode) {
|
||||
walk_definition_item(self, node);
|
||||
}
|
||||
fn visit_table(&mut self, node: &TableNode) {
|
||||
walk_table(self, node);
|
||||
}
|
||||
fn visit_table_row(&mut self, node: &TableRowNode) {
|
||||
walk_table_row(self, node);
|
||||
}
|
||||
fn visit_table_cell(&mut self, node: &TableCellNode) {
|
||||
for child in &node.children {
|
||||
self.visit_inline(child);
|
||||
}
|
||||
}
|
||||
fn visit_comment(&mut self, _node: &CommentNode) {}
|
||||
fn visit_tag(&mut self, _node: &TagNode) {}
|
||||
fn visit_error(&mut self, _node: &ErrorNode) {}
|
||||
|
||||
// Inline leaves and recursive inline nodes
|
||||
fn visit_text(&mut self, _node: &TextNode) {}
|
||||
fn visit_bold(&mut self, node: &BoldNode) {
|
||||
for child in &node.children {
|
||||
self.visit_inline(child);
|
||||
}
|
||||
}
|
||||
fn visit_italic(&mut self, node: &ItalicNode) {
|
||||
for child in &node.children {
|
||||
self.visit_inline(child);
|
||||
}
|
||||
}
|
||||
fn visit_bold_italic(&mut self, node: &BoldItalicNode) {
|
||||
for child in &node.children {
|
||||
self.visit_inline(child);
|
||||
}
|
||||
}
|
||||
fn visit_strikethrough(&mut self, node: &StrikethroughNode) {
|
||||
for child in &node.children {
|
||||
self.visit_inline(child);
|
||||
}
|
||||
}
|
||||
fn visit_code(&mut self, _node: &CodeNode) {}
|
||||
fn visit_superscript(&mut self, node: &SuperscriptNode) {
|
||||
for child in &node.children {
|
||||
self.visit_inline(child);
|
||||
}
|
||||
}
|
||||
fn visit_subscript(&mut self, node: &SubscriptNode) {
|
||||
for child in &node.children {
|
||||
self.visit_inline(child);
|
||||
}
|
||||
}
|
||||
fn visit_math_inline(&mut self, _node: &MathInlineNode) {}
|
||||
fn visit_keyword(&mut self, _node: &KeywordNode) {}
|
||||
fn visit_color(&mut self, node: &ColorNode) {
|
||||
for child in &node.children {
|
||||
self.visit_inline(child);
|
||||
}
|
||||
}
|
||||
fn visit_wiki_link(&mut self, node: &WikiLinkNode) {
|
||||
if let Some(desc) = &node.description {
|
||||
for child in desc {
|
||||
self.visit_inline(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
fn visit_external_link(&mut self, node: &ExternalLinkNode) {
|
||||
if let Some(desc) = &node.description {
|
||||
for child in desc {
|
||||
self.visit_inline(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
fn visit_transclusion(&mut self, _node: &TransclusionNode) {}
|
||||
fn visit_raw_url(&mut self, _node: &RawUrlNode) {}
|
||||
}
|
||||
|
||||
pub fn walk_document<V: Visitor + ?Sized>(v: &mut V, node: &DocumentNode) {
|
||||
for child in &node.children {
|
||||
v.visit_block(child);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn walk_block<V: Visitor + ?Sized>(v: &mut V, node: &BlockNode) {
|
||||
match node {
|
||||
BlockNode::Heading(n) => v.visit_heading(n),
|
||||
BlockNode::Paragraph(n) => v.visit_paragraph(n),
|
||||
BlockNode::HorizontalRule(n) => v.visit_horizontal_rule(n),
|
||||
BlockNode::Blockquote(n) => v.visit_blockquote(n),
|
||||
BlockNode::Preformatted(n) => v.visit_preformatted(n),
|
||||
BlockNode::MathBlock(n) => v.visit_math_block(n),
|
||||
BlockNode::List(n) => v.visit_list(n),
|
||||
BlockNode::DefinitionList(n) => v.visit_definition_list(n),
|
||||
BlockNode::Table(n) => v.visit_table(n),
|
||||
BlockNode::Comment(n) => v.visit_comment(n),
|
||||
BlockNode::Tag(n) => v.visit_tag(n),
|
||||
BlockNode::Error(n) => v.visit_error(n),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn walk_inline<V: Visitor + ?Sized>(v: &mut V, node: &InlineNode) {
|
||||
match node {
|
||||
InlineNode::Text(n) => v.visit_text(n),
|
||||
InlineNode::Bold(n) => v.visit_bold(n),
|
||||
InlineNode::Italic(n) => v.visit_italic(n),
|
||||
InlineNode::BoldItalic(n) => v.visit_bold_italic(n),
|
||||
InlineNode::Strikethrough(n) => v.visit_strikethrough(n),
|
||||
InlineNode::Code(n) => v.visit_code(n),
|
||||
InlineNode::Superscript(n) => v.visit_superscript(n),
|
||||
InlineNode::Subscript(n) => v.visit_subscript(n),
|
||||
InlineNode::MathInline(n) => v.visit_math_inline(n),
|
||||
InlineNode::Keyword(n) => v.visit_keyword(n),
|
||||
InlineNode::Color(n) => v.visit_color(n),
|
||||
InlineNode::WikiLink(n) => v.visit_wiki_link(n),
|
||||
InlineNode::ExternalLink(n) => v.visit_external_link(n),
|
||||
InlineNode::Transclusion(n) => v.visit_transclusion(n),
|
||||
InlineNode::RawUrl(n) => v.visit_raw_url(n),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn walk_list<V: Visitor + ?Sized>(v: &mut V, node: &ListNode) {
|
||||
for item in &node.items {
|
||||
v.visit_list_item(item);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn walk_list_item<V: Visitor + ?Sized>(v: &mut V, node: &ListItemNode) {
|
||||
for child in &node.children {
|
||||
v.visit_inline(child);
|
||||
}
|
||||
if let Some(sublist) = &node.sublist {
|
||||
v.visit_list(sublist);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn walk_definition_item<V: Visitor + ?Sized>(v: &mut V, node: &DefinitionItemNode) {
|
||||
if let Some(term) = &node.term {
|
||||
for child in term {
|
||||
v.visit_inline(child);
|
||||
}
|
||||
}
|
||||
for definition in &node.definitions {
|
||||
for child in definition {
|
||||
v.visit_inline(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn walk_table<V: Visitor + ?Sized>(v: &mut V, node: &TableNode) {
|
||||
for row in &node.rows {
|
||||
v.visit_table_row(row);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn walk_table_row<V: Visitor + ?Sized>(v: &mut V, node: &TableRowNode) {
|
||||
for cell in &node.cells {
|
||||
v.visit_table_cell(cell);
|
||||
}
|
||||
}
|
||||
@@ -1,415 +0,0 @@
|
||||
//! Date primitives for the diary subsystem.
|
||||
//!
|
||||
//! This crate deliberately doesn't pull in `chrono` or `time`. The diary feature
|
||||
//! only needs `YYYY-MM-DD` parsing/formatting and ±1 day arithmetic, which
|
||||
//! is small enough that the dependency cost outweighs the convenience.
|
||||
//!
|
||||
//! Date math uses Howard Hinnant's days-from-civil algorithm
|
||||
//! (<https://howardhinnant.github.io/date_algorithms.html>) — proleptic
|
||||
//! Gregorian, valid for any year representable in `i32`.
|
||||
|
||||
use std::cmp::Ordering;
|
||||
use std::fmt;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// A calendar day, decoupled from any time zone or wall-clock context.
|
||||
///
|
||||
/// Validation: `parse` and `from_ymd` reject impossible dates (month 0,
|
||||
/// day 0, day > days-in-month, etc.). The struct fields are public so
|
||||
/// callers can read them, but constructing one with bogus values bypasses
|
||||
/// validation — use the constructors.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct DiaryDate {
|
||||
pub year: i32,
|
||||
pub month: u8,
|
||||
pub day: u8,
|
||||
}
|
||||
|
||||
impl DiaryDate {
|
||||
/// Build a date from its components, validating the calendar.
|
||||
pub fn from_ymd(year: i32, month: u8, day: u8) -> Option<Self> {
|
||||
if !(1..=12).contains(&month) {
|
||||
return None;
|
||||
}
|
||||
if day < 1 || day > days_in_month(year, month) {
|
||||
return None;
|
||||
}
|
||||
Some(Self { year, month, day })
|
||||
}
|
||||
|
||||
/// Parse a strict `YYYY-MM-DD` string. Rejects any deviation — extra
|
||||
/// whitespace, missing zero-padding, alternate separators, etc.
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
let b = s.as_bytes();
|
||||
if b.len() != 10 || b[4] != b'-' || b[7] != b'-' {
|
||||
return None;
|
||||
}
|
||||
let y = parse_digits(&b[0..4])?;
|
||||
let m = parse_digits(&b[5..7])? as u8;
|
||||
let d = parse_digits(&b[8..10])? as u8;
|
||||
Self::from_ymd(y as i32, m, d)
|
||||
}
|
||||
|
||||
/// Format as `YYYY-MM-DD`. Years outside 0..=9999 still render
|
||||
/// numerically; the diary use case never produces those.
|
||||
pub fn format(&self) -> String {
|
||||
format!("{:04}-{:02}-{:02}", self.year, self.month, self.day)
|
||||
}
|
||||
|
||||
/// UTC "today" computed from `SystemTime::now()`. The diary
|
||||
/// commands intentionally use UTC — local-time semantics depend on a
|
||||
/// timezone DB we don't ship and would surprise users crossing DST
|
||||
/// boundaries.
|
||||
pub fn today_utc() -> Self {
|
||||
let secs = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
let days = (secs / 86_400) as i64;
|
||||
from_days(days)
|
||||
}
|
||||
|
||||
pub fn next_day(&self) -> Self {
|
||||
from_days(to_days(self.year, self.month, self.day) + 1)
|
||||
}
|
||||
|
||||
pub fn prev_day(&self) -> Self {
|
||||
from_days(to_days(self.year, self.month, self.day) - 1)
|
||||
}
|
||||
|
||||
/// Internal — exposed for tests that want to verify ordering against
|
||||
/// the days-from-epoch representation.
|
||||
pub fn to_days_epoch(&self) -> i64 {
|
||||
to_days(self.year, self.month, self.day)
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialOrd for DiaryDate {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
|
||||
impl Ord for DiaryDate {
|
||||
fn cmp(&self, other: &Self) -> Ordering {
|
||||
self.to_days_epoch().cmp(&other.to_days_epoch())
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for DiaryDate {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.format())
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_digits(b: &[u8]) -> Option<u32> {
|
||||
if b.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut n: u32 = 0;
|
||||
for ch in b {
|
||||
if !ch.is_ascii_digit() {
|
||||
return None;
|
||||
}
|
||||
n = n.checked_mul(10)?.checked_add((*ch - b'0') as u32)?;
|
||||
}
|
||||
Some(n)
|
||||
}
|
||||
|
||||
fn is_leap(year: i32) -> bool {
|
||||
(year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
|
||||
}
|
||||
|
||||
fn days_in_month(year: i32, month: u8) -> u8 {
|
||||
match month {
|
||||
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
|
||||
4 | 6 | 9 | 11 => 30,
|
||||
2 => {
|
||||
if is_leap(year) {
|
||||
29
|
||||
} else {
|
||||
28
|
||||
}
|
||||
}
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Howard Hinnant's `days_from_civil`: 1970-01-01 → 0.
|
||||
fn to_days(year: i32, month: u8, day: u8) -> i64 {
|
||||
let y = year as i64 - if (month as i64) <= 2 { 1 } else { 0 };
|
||||
let era = y.div_euclid(400);
|
||||
let yoe = y.rem_euclid(400);
|
||||
let m = month as i64;
|
||||
let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + day as i64 - 1;
|
||||
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
|
||||
era * 146_097 + doe - 719_468
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Diary frequency / period
|
||||
// ============================================================
|
||||
//
|
||||
// `DiaryFrequency` is the user-configured cadence (mirrors vimwiki's
|
||||
// `diary_frequency` setting). `DiaryPeriod` is the *concrete* diary
|
||||
// entry — a specific day, ISO week, calendar month, or year — that
|
||||
// the LSP commands compute and the renderer addresses.
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum DiaryFrequency {
|
||||
Daily,
|
||||
Weekly,
|
||||
Monthly,
|
||||
Yearly,
|
||||
}
|
||||
|
||||
impl DiaryFrequency {
|
||||
/// Parse the string form used in `WikiConfig.diary_frequency`.
|
||||
/// Falls back to `Daily` for unrecognised values — vimwiki's
|
||||
/// permissive behaviour for stale configs.
|
||||
pub fn parse(s: &str) -> Self {
|
||||
match s.trim().to_ascii_lowercase().as_str() {
|
||||
"weekly" | "week" => Self::Weekly,
|
||||
"monthly" | "month" => Self::Monthly,
|
||||
"yearly" | "year" => Self::Yearly,
|
||||
_ => Self::Daily,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Daily => "daily",
|
||||
Self::Weekly => "weekly",
|
||||
Self::Monthly => "monthly",
|
||||
Self::Yearly => "yearly",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A specific diary entry, identified by its calendar period. The four
|
||||
/// variants share the same `format` / `parse` / `next` / `prev` /
|
||||
/// `today_utc` surface so the LSP dispatcher can stay frequency-agnostic.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum DiaryPeriod {
|
||||
Day(DiaryDate),
|
||||
/// ISO 8601 week — year/week pair, week ∈ 1..=53.
|
||||
Week {
|
||||
iso_year: i32,
|
||||
iso_week: u8,
|
||||
},
|
||||
Month {
|
||||
year: i32,
|
||||
month: u8,
|
||||
},
|
||||
Year {
|
||||
year: i32,
|
||||
},
|
||||
}
|
||||
|
||||
impl DiaryPeriod {
|
||||
/// "Now" for the given frequency, in UTC. See `DiaryDate::today_utc`
|
||||
/// for the timezone rationale.
|
||||
pub fn today_utc(freq: DiaryFrequency) -> Self {
|
||||
let today = DiaryDate::today_utc();
|
||||
match freq {
|
||||
DiaryFrequency::Daily => Self::Day(today),
|
||||
DiaryFrequency::Weekly => Self::week_containing(today),
|
||||
DiaryFrequency::Monthly => Self::Month {
|
||||
year: today.year,
|
||||
month: today.month,
|
||||
},
|
||||
DiaryFrequency::Yearly => Self::Year { year: today.year },
|
||||
}
|
||||
}
|
||||
|
||||
/// File-stem format — also the format vimwiki uses for diary link
|
||||
/// targets (`[[diary:2026-W19]]`, `[[diary:2026-05]]`, etc.).
|
||||
///
|
||||
/// Day(2026-05-12) → `2026-05-12`
|
||||
/// Week(2026, 19) → `2026-W19`
|
||||
/// Month(2026, 5) → `2026-05`
|
||||
/// Year(2026) → `2026`
|
||||
pub fn format(&self) -> String {
|
||||
match self {
|
||||
Self::Day(d) => d.format(),
|
||||
Self::Week { iso_year, iso_week } => format!("{iso_year:04}-W{iso_week:02}"),
|
||||
Self::Month { year, month } => format!("{year:04}-{month:02}"),
|
||||
Self::Year { year } => format!("{year:04}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Inverse of `format` — recognise any of the four stems. Returns
|
||||
/// `None` for inputs that don't match any flavour.
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
// Day: `YYYY-MM-DD`
|
||||
if let Some(d) = DiaryDate::parse(s) {
|
||||
return Some(Self::Day(d));
|
||||
}
|
||||
let b = s.as_bytes();
|
||||
// Week: `YYYY-Www`
|
||||
if b.len() == 8 && b[4] == b'-' && (b[5] == b'W' || b[5] == b'w') {
|
||||
let y = parse_digits(&b[0..4])? as i32;
|
||||
let w = parse_digits(&b[6..8])? as u8;
|
||||
if (1..=53).contains(&w) {
|
||||
return Some(Self::Week {
|
||||
iso_year: y,
|
||||
iso_week: w,
|
||||
});
|
||||
}
|
||||
}
|
||||
// Month: `YYYY-MM`
|
||||
if b.len() == 7 && b[4] == b'-' {
|
||||
let y = parse_digits(&b[0..4])? as i32;
|
||||
let m = parse_digits(&b[5..7])? as u8;
|
||||
if (1..=12).contains(&m) {
|
||||
return Some(Self::Month { year: y, month: m });
|
||||
}
|
||||
}
|
||||
// Year: `YYYY`
|
||||
if b.len() == 4 {
|
||||
let y = parse_digits(&b[0..4])? as i32;
|
||||
return Some(Self::Year { year: y });
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn next(&self) -> Self {
|
||||
match *self {
|
||||
Self::Day(d) => Self::Day(d.next_day()),
|
||||
Self::Week { iso_year, iso_week } => {
|
||||
// Step forward one week: take the Monday of this ISO week,
|
||||
// add 7 days, recompute the (year, week) it falls in.
|
||||
let mon = monday_of_iso_week(iso_year, iso_week);
|
||||
let next_mon = from_days(to_days(mon.year, mon.month, mon.day) + 7);
|
||||
let (iso_year, iso_week) = iso_year_week(&next_mon);
|
||||
Self::Week { iso_year, iso_week }
|
||||
}
|
||||
Self::Month { year, month } => {
|
||||
if month == 12 {
|
||||
Self::Month {
|
||||
year: year + 1,
|
||||
month: 1,
|
||||
}
|
||||
} else {
|
||||
Self::Month {
|
||||
year,
|
||||
month: month + 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
Self::Year { year } => Self::Year { year: year + 1 },
|
||||
}
|
||||
}
|
||||
|
||||
pub fn prev(&self) -> Self {
|
||||
match *self {
|
||||
Self::Day(d) => Self::Day(d.prev_day()),
|
||||
Self::Week { iso_year, iso_week } => {
|
||||
let mon = monday_of_iso_week(iso_year, iso_week);
|
||||
let prev_mon = from_days(to_days(mon.year, mon.month, mon.day) - 7);
|
||||
let (iso_year, iso_week) = iso_year_week(&prev_mon);
|
||||
Self::Week { iso_year, iso_week }
|
||||
}
|
||||
Self::Month { year, month } => {
|
||||
if month == 1 {
|
||||
Self::Month {
|
||||
year: year - 1,
|
||||
month: 12,
|
||||
}
|
||||
} else {
|
||||
Self::Month {
|
||||
year,
|
||||
month: month - 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
Self::Year { year } => Self::Year { year: year - 1 },
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute the ISO week containing the given calendar date.
|
||||
pub fn week_containing(d: DiaryDate) -> Self {
|
||||
let (iso_year, iso_week) = iso_year_week(&d);
|
||||
Self::Week { iso_year, iso_week }
|
||||
}
|
||||
|
||||
/// First calendar day of this period — useful for chronological
|
||||
/// sorting across mixed-frequency entries.
|
||||
///
|
||||
/// Day(d) → d
|
||||
/// Week(y,w) → Monday of that ISO week
|
||||
/// Month(y,m) → 1st of that month
|
||||
/// Year(y) → Jan 1st of that year
|
||||
pub fn first_day(&self) -> DiaryDate {
|
||||
match *self {
|
||||
Self::Day(d) => d,
|
||||
Self::Week { iso_year, iso_week } => monday_of_iso_week(iso_year, iso_week),
|
||||
Self::Month { year, month } => {
|
||||
DiaryDate::from_ymd(year, month, 1).expect("validated month")
|
||||
}
|
||||
Self::Year { year } => DiaryDate::from_ymd(year, 1, 1).expect("Jan 1st always valid"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for DiaryPeriod {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.format())
|
||||
}
|
||||
}
|
||||
|
||||
/// Day-of-week with Monday=1 … Sunday=7 (ISO 8601).
|
||||
fn iso_weekday(d: &DiaryDate) -> u8 {
|
||||
// Zeller-style via days-from-epoch: 1970-01-01 was a Thursday.
|
||||
let days = to_days(d.year, d.month, d.day);
|
||||
// (days + 3) mod 7 puts Monday at 0 … Sunday at 6; shift to 1..=7.
|
||||
let r = (days + 3).rem_euclid(7);
|
||||
(r as u8) + 1
|
||||
}
|
||||
|
||||
/// ISO 8601 `(year, week)` for a calendar date. The ISO year may differ
|
||||
/// from the calendar year for early January / late December dates.
|
||||
fn iso_year_week(d: &DiaryDate) -> (i32, u8) {
|
||||
// Standard algorithm: shift to the Thursday of the same ISO week,
|
||||
// then ISO year = thursday.year, ISO week = ordinal_day(thursday) / 7 + 1.
|
||||
let weekday = iso_weekday(d) as i64; // 1..=7
|
||||
let days = to_days(d.year, d.month, d.day);
|
||||
let thursday = from_days(days + 4 - weekday);
|
||||
let jan1 = to_days(thursday.year, 1, 1);
|
||||
let ordinal = to_days(thursday.year, thursday.month, thursday.day) - jan1 + 1;
|
||||
let week = ((ordinal - 1) / 7 + 1) as u8;
|
||||
(thursday.year, week)
|
||||
}
|
||||
|
||||
/// Date of the Monday that opens the given ISO `(year, week)`.
|
||||
fn monday_of_iso_week(iso_year: i32, iso_week: u8) -> DiaryDate {
|
||||
// Find Jan 4th of iso_year — always in ISO week 1 by definition.
|
||||
let jan4 = DiaryDate {
|
||||
year: iso_year,
|
||||
month: 1,
|
||||
day: 4,
|
||||
};
|
||||
let jan4_weekday = iso_weekday(&jan4) as i64; // 1..=7
|
||||
let week1_monday_days = to_days(jan4.year, jan4.month, jan4.day) - (jan4_weekday - 1);
|
||||
let target = week1_monday_days + (iso_week as i64 - 1) * 7;
|
||||
from_days(target)
|
||||
}
|
||||
|
||||
/// Inverse of [`to_days`].
|
||||
fn from_days(days: i64) -> DiaryDate {
|
||||
let z = days + 719_468;
|
||||
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
|
||||
let doe = (z - era * 146_097) as u64;
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let y = yoe as i64 + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = (doy - (153 * mp + 2) / 5 + 1) as u8;
|
||||
let m_int = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let year = y + if m_int <= 2 { 1 } else { 0 };
|
||||
DiaryDate {
|
||||
year: year as i32,
|
||||
month: m_int as u8,
|
||||
day: d,
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
//! Core parser, AST, and renderer for nuwiki.
|
||||
//!
|
||||
//! This crate is editor-independent and must never depend on `nuwiki-lsp` or
|
||||
//! `nuwiki-ls`.
|
||||
|
||||
pub mod ast;
|
||||
pub mod date;
|
||||
pub mod listsyms;
|
||||
pub mod render;
|
||||
pub mod syntax;
|
||||
@@ -1,170 +0,0 @@
|
||||
//! Configurable checkbox progress palette (vimwiki's `g:vimwiki_listsyms`).
|
||||
//!
|
||||
//! A palette is a progression of glyphs from "empty" (index 0) to "done"
|
||||
//! (the last index). The default `" .oOX"` is the stock vimwiki palette.
|
||||
//! A separate `rejected` glyph (`-`) is fixed by convention.
|
||||
//!
|
||||
//! The palette maps glyphs to a normalised five-bucket [`CheckboxState`] for
|
||||
//! the AST (so the HTML renderer keeps its fixed `done0..done4` classes) while
|
||||
//! still letting the LSP cycle/toggle commands operate on the real glyphs.
|
||||
|
||||
use crate::ast::CheckboxState;
|
||||
|
||||
const REJECTED: char = '-';
|
||||
|
||||
/// An ordered checkbox progress palette. Always holds at least two glyphs.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ListSyms {
|
||||
progression: Vec<char>,
|
||||
}
|
||||
|
||||
impl Default for ListSyms {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
progression: " .oOX".chars().collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ListSyms {
|
||||
/// Build a palette from a glyph string. Falls back to the default when
|
||||
/// fewer than two glyphs are supplied (a one-symbol palette can't express
|
||||
/// both "empty" and "done").
|
||||
pub fn new(s: &str) -> Self {
|
||||
let progression: Vec<char> = s.chars().collect();
|
||||
if progression.len() < 2 {
|
||||
Self::default()
|
||||
} else {
|
||||
Self { progression }
|
||||
}
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.progression.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.progression.is_empty()
|
||||
}
|
||||
|
||||
pub fn empty_glyph(&self) -> char {
|
||||
self.progression[0]
|
||||
}
|
||||
|
||||
pub fn done_glyph(&self) -> char {
|
||||
*self.progression.last().unwrap()
|
||||
}
|
||||
|
||||
pub fn rejected_glyph(&self) -> char {
|
||||
REJECTED
|
||||
}
|
||||
|
||||
pub fn glyph_at(&self, index: usize) -> char {
|
||||
self.progression[index.min(self.progression.len() - 1)]
|
||||
}
|
||||
|
||||
pub fn index_of(&self, c: char) -> Option<usize> {
|
||||
self.progression.iter().position(|&g| g == c)
|
||||
}
|
||||
|
||||
/// Progress rate (0..=100) for a glyph index.
|
||||
pub fn rate(&self, index: usize) -> i32 {
|
||||
let last = (self.progression.len() - 1) as i32;
|
||||
(index as i32) * 100 / last
|
||||
}
|
||||
|
||||
/// Normalised five-bucket [`CheckboxState`] for a glyph, or `None` when the
|
||||
/// glyph is not part of this palette (and is not the rejected marker).
|
||||
pub fn state_of(&self, c: char) -> Option<CheckboxState> {
|
||||
if c == REJECTED {
|
||||
return Some(CheckboxState::Rejected);
|
||||
}
|
||||
let index = self.index_of(c)?;
|
||||
let rate = self.rate(index);
|
||||
// Round the rate to the nearest 25% bucket.
|
||||
Some(match (rate + 12) / 25 {
|
||||
0 => CheckboxState::Empty,
|
||||
1 => CheckboxState::Quarter,
|
||||
2 => CheckboxState::Half,
|
||||
3 => CheckboxState::ThreeQuarters,
|
||||
_ => CheckboxState::Done,
|
||||
})
|
||||
}
|
||||
|
||||
/// Snap a rate (`-1` = rejected, else 0..=100) to a glyph. Intermediate
|
||||
/// rates distribute over the `n - 2` non-terminal glyphs using vimwiki's
|
||||
/// `ceil` rule, matching the stock five-symbol behaviour.
|
||||
pub fn glyph_for_rate(&self, rate: i32) -> char {
|
||||
if rate < 0 {
|
||||
return REJECTED;
|
||||
}
|
||||
if rate <= 0 {
|
||||
return self.empty_glyph();
|
||||
}
|
||||
if rate >= 100 {
|
||||
return self.done_glyph();
|
||||
}
|
||||
let mids = (self.progression.len().saturating_sub(2)).max(1) as f64;
|
||||
let index = ((rate as f64) / 100.0 * mids).ceil() as usize;
|
||||
self.glyph_at(index)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_palette_matches_stock_vimwiki() {
|
||||
let s = ListSyms::default();
|
||||
assert_eq!(s.len(), 5);
|
||||
assert_eq!(s.empty_glyph(), ' ');
|
||||
assert_eq!(s.done_glyph(), 'X');
|
||||
assert_eq!(s.rejected_glyph(), '-');
|
||||
assert_eq!(s.rate(0), 0);
|
||||
assert_eq!(s.rate(1), 25);
|
||||
assert_eq!(s.rate(2), 50);
|
||||
assert_eq!(s.rate(3), 75);
|
||||
assert_eq!(s.rate(4), 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_state_of_round_trips_each_glyph() {
|
||||
let s = ListSyms::default();
|
||||
assert_eq!(s.state_of(' '), Some(CheckboxState::Empty));
|
||||
assert_eq!(s.state_of('.'), Some(CheckboxState::Quarter));
|
||||
assert_eq!(s.state_of('o'), Some(CheckboxState::Half));
|
||||
assert_eq!(s.state_of('O'), Some(CheckboxState::ThreeQuarters));
|
||||
assert_eq!(s.state_of('X'), Some(CheckboxState::Done));
|
||||
assert_eq!(s.state_of('-'), Some(CheckboxState::Rejected));
|
||||
assert_eq!(s.state_of('?'), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_glyph_for_rate_matches_legacy_buckets() {
|
||||
let s = ListSyms::default();
|
||||
assert_eq!(s.glyph_for_rate(-1), '-');
|
||||
assert_eq!(s.glyph_for_rate(0), ' ');
|
||||
assert_eq!(s.glyph_for_rate(25), '.');
|
||||
assert_eq!(s.glyph_for_rate(50), 'o');
|
||||
assert_eq!(s.glyph_for_rate(75), 'O');
|
||||
assert_eq!(s.glyph_for_rate(100), 'X');
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_input_falls_back_to_default() {
|
||||
assert_eq!(ListSyms::new(""), ListSyms::default());
|
||||
assert_eq!(ListSyms::new("x"), ListSyms::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_palette_buckets_to_nearest_state() {
|
||||
// 3 glyphs → rates 0 / 50 / 100.
|
||||
let s = ListSyms::new(" x✓");
|
||||
assert_eq!(s.state_of(' '), Some(CheckboxState::Empty));
|
||||
assert_eq!(s.state_of('x'), Some(CheckboxState::Half));
|
||||
assert_eq!(s.state_of('✓'), Some(CheckboxState::Done));
|
||||
assert_eq!(s.glyph_for_rate(50), 'x');
|
||||
assert_eq!(s.glyph_for_rate(100), '✓');
|
||||
}
|
||||
}
|
||||
@@ -1,790 +0,0 @@
|
||||
//! HTML renderer.
|
||||
//!
|
||||
//! Walks a `DocumentNode` and writes HTML5 fragments. The renderer is
|
||||
//! configured with a link resolver (callback that turns a `LinkTarget` into
|
||||
//! a URL string) and, optionally, an enclosing template with substitution
|
||||
//! placeholders.
|
||||
//!
|
||||
//! Substitution model: `{{content}}` and `{{title}}` are computed from
|
||||
//! the rendered body and the document's metadata; `with_var(k, v)` /
|
||||
//! `with_vars(map)` add arbitrary key→value pairs (`{{date}}`,
|
||||
//! `{{root_path}}`, `{{toc}}`). Order: `{{content}}` is
|
||||
//! substituted first so a body that happens to contain a literal
|
||||
//! `{{title}}` isn't itself rewritten in the next pass.
|
||||
//!
|
||||
//! Both delimiter styles are recognised for every placeholder: nuwiki's
|
||||
//! native `{{key}}` and vimwiki's `%key%` (`%title%`, `%content%`,
|
||||
//! `%root_path%`, `%date%`, `%wiki_css%`, …). This lets stock vimwiki
|
||||
//! templates render unchanged.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io::{self, Write};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::ast::{
|
||||
BlockNode, BlockquoteNode, BoldItalicNode, BoldNode, CheckboxState, CodeNode, ColorNode,
|
||||
CommentNode, DefinitionItemNode, DefinitionListNode, DocumentNode, ErrorNode, ExternalLinkNode,
|
||||
HeadingNode, HorizontalRuleNode, InlineNode, ItalicNode, Keyword, KeywordNode, LinkKind,
|
||||
LinkTarget, ListItemNode, ListNode, MathBlockNode, MathInlineNode, ParagraphNode,
|
||||
PreformattedNode, RawUrlNode, StrikethroughNode, SubscriptNode, SuperscriptNode, TableCellNode,
|
||||
TableNode, TableRowNode, TagNode, TextNode, TransclusionNode, WikiLinkNode,
|
||||
};
|
||||
|
||||
use super::Renderer;
|
||||
|
||||
/// Boxed link-resolution callback. Held in an `Arc` so the renderer is
|
||||
/// cheap to clone and stays `Send + Sync`.
|
||||
pub type LinkResolver = Arc<dyn Fn(&LinkTarget) -> String + Send + Sync>;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct HtmlRenderer {
|
||||
link_resolver: LinkResolver,
|
||||
template: Option<String>,
|
||||
vars: HashMap<String, String>,
|
||||
/// `color_dic`-style override: vimwiki colour-tag names → CSS
|
||||
/// values (`"red"` / `"#ffe119"` / `"oklch(…)"`). Unspecified
|
||||
/// names fall through to the default `class="color-<name>"`
|
||||
/// rendering. Matches vimwiki's `color_dic`.
|
||||
colors: HashMap<String, String>,
|
||||
/// vimwiki `list_margin`: left margin (in `em`) applied to the
|
||||
/// outermost list. `-1` (the default) emits no inline style and
|
||||
/// defers to the stylesheet; `>= 0` forces `margin-left:<n>em`.
|
||||
list_margin: i32,
|
||||
}
|
||||
|
||||
impl Default for HtmlRenderer {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl HtmlRenderer {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
link_resolver: Arc::new(default_link_resolver),
|
||||
template: None,
|
||||
vars: HashMap::new(),
|
||||
colors: HashMap::new(),
|
||||
list_margin: -1,
|
||||
}
|
||||
}
|
||||
|
||||
/// Override the link resolver. The callback is invoked for every
|
||||
/// wikilink and is expected to return a URL string.
|
||||
pub fn with_link_resolver<F>(mut self, f: F) -> Self
|
||||
where
|
||||
F: Fn(&LinkTarget) -> String + Send + Sync + 'static,
|
||||
{
|
||||
self.link_resolver = Arc::new(f);
|
||||
self
|
||||
}
|
||||
|
||||
/// Wrap the rendered body in a template. `{{title}}` and `{{content}}`
|
||||
/// are substituted automatically; additional `{{key}}` placeholders
|
||||
/// resolve via `with_var` / `with_vars`. Anything unresolved passes
|
||||
/// through verbatim.
|
||||
pub fn with_template(mut self, template: impl Into<String>) -> Self {
|
||||
self.template = Some(template.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Add a single template variable. Re-use to overwrite.
|
||||
pub fn with_var(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
|
||||
self.vars.insert(key.into(), value.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Replace the entire template variable map.
|
||||
pub fn with_vars(mut self, vars: HashMap<String, String>) -> Self {
|
||||
self.vars = vars;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the vimwiki `list_margin`. `-1` defers to the stylesheet;
|
||||
/// `>= 0` forces a `margin-left:<n>em` on the outermost list.
|
||||
pub fn with_list_margin(mut self, margin: i32) -> Self {
|
||||
self.list_margin = margin;
|
||||
self
|
||||
}
|
||||
|
||||
/// Supply a `color_dic`: vimwiki colour-tag names mapped to their
|
||||
/// CSS values. A name listed here is rendered as `style="color:V"`;
|
||||
/// missing names fall through to `class="color-<name>"`.
|
||||
pub fn with_colors(mut self, colors: HashMap<String, String>) -> Self {
|
||||
self.colors = colors;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Renderer for HtmlRenderer {
|
||||
fn render(&self, doc: &DocumentNode, w: &mut dyn Write) -> io::Result<()> {
|
||||
if let Some(template) = &self.template {
|
||||
let mut body = Vec::new();
|
||||
self.render_body(doc, &mut body)?;
|
||||
let body_str = std::str::from_utf8(&body)
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
|
||||
let title = doc.metadata.title.as_deref().unwrap_or("");
|
||||
// Substitute `content` before `title` so a body that legitimately
|
||||
// contains a literal title placeholder isn't rewritten. Both the
|
||||
// native `{{key}}` and vimwiki's `%key%` delimiters are accepted so
|
||||
// stock vimwiki templates render unchanged.
|
||||
let mut out = template.replace("{{content}}", body_str);
|
||||
out = out.replace("%content%", body_str);
|
||||
out = out.replace("{{title}}", title);
|
||||
out = out.replace("%title%", title);
|
||||
for (k, v) in &self.vars {
|
||||
out = out.replace(&format!("{{{{{k}}}}}"), v);
|
||||
out = out.replace(&format!("%{k}%"), v);
|
||||
}
|
||||
w.write_all(out.as_bytes())?;
|
||||
} else {
|
||||
self.render_body(doc, w)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl HtmlRenderer {
|
||||
fn render_body(&self, doc: &DocumentNode, w: &mut dyn Write) -> io::Result<()> {
|
||||
for block in &doc.children {
|
||||
self.render_block(block, w)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn render_block(&self, block: &BlockNode, w: &mut dyn Write) -> io::Result<()> {
|
||||
match block {
|
||||
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),
|
||||
BlockNode::Preformatted(n) => self.render_preformatted(n, w),
|
||||
BlockNode::MathBlock(n) => self.render_math_block(n, w),
|
||||
BlockNode::List(n) => self.render_list(n, w),
|
||||
BlockNode::DefinitionList(n) => self.render_definition_list(n, w),
|
||||
BlockNode::Table(n) => self.render_table(n, w),
|
||||
BlockNode::Comment(n) => self.render_comment(n, w),
|
||||
BlockNode::Tag(n) => self.render_tag(n, w),
|
||||
BlockNode::Error(n) => self.render_error(n, w),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_tag(&self, n: &TagNode, w: &mut dyn Write) -> io::Result<()> {
|
||||
// `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=\"tag-")?;
|
||||
write_escaped(name, w)?;
|
||||
w.write_all(b"\">")?;
|
||||
write_escaped(name, w)?;
|
||||
w.write_all(b"</span>")?;
|
||||
}
|
||||
w.write_all(b"</div>\n")
|
||||
}
|
||||
|
||||
fn render_heading(&self, n: &HeadingNode, w: &mut dyn Write) -> io::Result<()> {
|
||||
let level = n.level.clamp(1, 6);
|
||||
let class = if n.centered {
|
||||
" class=\"centered\""
|
||||
} else {
|
||||
""
|
||||
};
|
||||
write!(w, "<h{level}{class}>")?;
|
||||
self.render_inlines(&n.children, w)?;
|
||||
writeln!(w, "</h{level}>")
|
||||
}
|
||||
|
||||
fn render_paragraph(&self, n: &ParagraphNode, w: &mut dyn Write) -> io::Result<()> {
|
||||
w.write_all(b"<p>")?;
|
||||
self.render_inlines(&n.children, w)?;
|
||||
w.write_all(b"</p>\n")
|
||||
}
|
||||
|
||||
fn render_hr(&self, _n: &HorizontalRuleNode, w: &mut dyn Write) -> io::Result<()> {
|
||||
w.write_all(b"<hr>\n")
|
||||
}
|
||||
|
||||
fn render_blockquote(&self, n: &BlockquoteNode, w: &mut dyn Write) -> io::Result<()> {
|
||||
w.write_all(b"<blockquote>\n")?;
|
||||
for child in &n.children {
|
||||
self.render_block(child, w)?;
|
||||
}
|
||||
w.write_all(b"</blockquote>\n")
|
||||
}
|
||||
|
||||
fn render_preformatted(&self, n: &PreformattedNode, w: &mut dyn Write) -> io::Result<()> {
|
||||
match &n.language {
|
||||
Some(lang) => {
|
||||
w.write_all(b"<pre><code class=\"language-")?;
|
||||
write_escaped(lang, w)?;
|
||||
w.write_all(b"\">")?;
|
||||
}
|
||||
None => w.write_all(b"<pre><code>")?,
|
||||
}
|
||||
write_escaped(&n.content, w)?;
|
||||
w.write_all(b"</code></pre>\n")
|
||||
}
|
||||
|
||||
fn render_math_block(&self, n: &MathBlockNode, w: &mut dyn Write) -> io::Result<()> {
|
||||
// No actual TeX rendering — caller is expected to run MathJax / KaTeX
|
||||
// over the wrapped node. The delimiters match vimwiki convention.
|
||||
match &n.environment {
|
||||
Some(env) => writeln!(
|
||||
w,
|
||||
"<div class=\"math-block\" data-env=\"{}\">\\begin{{{env}}}",
|
||||
escape(env)
|
||||
)?,
|
||||
None => w.write_all(b"<div class=\"math-block\">\\[\n")?,
|
||||
}
|
||||
write_escaped(&n.content, w)?;
|
||||
match &n.environment {
|
||||
Some(env) => write!(w, "\n\\end{{{env}}}</div>\n", env = escape(env))?,
|
||||
None => w.write_all(b"\n\\]</div>\n")?,
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn render_list(&self, n: &ListNode, w: &mut dyn Write) -> io::Result<()> {
|
||||
self.render_list_nested(n, w, true)
|
||||
}
|
||||
|
||||
fn render_list_nested(&self, n: &ListNode, w: &mut dyn Write, top: bool) -> io::Result<()> {
|
||||
let tag = if n.ordered { "ol" } else { "ul" };
|
||||
if top && self.list_margin >= 0 {
|
||||
writeln!(w, "<{tag} style=\"margin-left:{}em\">", self.list_margin)?;
|
||||
} else {
|
||||
writeln!(w, "<{tag}>")?;
|
||||
}
|
||||
for item in &n.items {
|
||||
self.render_list_item(item, w)?;
|
||||
}
|
||||
writeln!(w, "</{tag}>")
|
||||
}
|
||||
|
||||
fn render_list_item(&self, n: &ListItemNode, w: &mut dyn Write) -> io::Result<()> {
|
||||
match n.checkbox {
|
||||
Some(state) => {
|
||||
// vimwiki-compatible checkbox classes: `[ ]`→done0, `[.]`→done1,
|
||||
// `[o]`→done2, `[O]`→done3, `[X]`→done4, `[-]`→rejected. The
|
||||
// checkbox glyph/progress fill is drawn entirely by the
|
||||
// stylesheet (`li.doneN::before`); vimwiki emits no `<input>`,
|
||||
// so neither do we — an input would double-render against it.
|
||||
let class = match state {
|
||||
CheckboxState::Empty => "done0",
|
||||
CheckboxState::Quarter => "done1",
|
||||
CheckboxState::Half => "done2",
|
||||
CheckboxState::ThreeQuarters => "done3",
|
||||
CheckboxState::Done => "done4",
|
||||
CheckboxState::Rejected => "rejected",
|
||||
};
|
||||
write!(w, "<li class=\"{class}\">")?;
|
||||
}
|
||||
None => w.write_all(b"<li>")?,
|
||||
}
|
||||
self.render_inlines(&n.children, w)?;
|
||||
if let Some(sublist) = &n.sublist {
|
||||
w.write_all(b"\n")?;
|
||||
self.render_list_nested(sublist, w, false)?;
|
||||
}
|
||||
w.write_all(b"</li>\n")
|
||||
}
|
||||
|
||||
fn render_definition_list(&self, n: &DefinitionListNode, w: &mut dyn Write) -> io::Result<()> {
|
||||
w.write_all(b"<dl>\n")?;
|
||||
for item in &n.items {
|
||||
self.render_definition_item(item, w)?;
|
||||
}
|
||||
w.write_all(b"</dl>\n")
|
||||
}
|
||||
|
||||
fn render_definition_item(&self, n: &DefinitionItemNode, w: &mut dyn Write) -> io::Result<()> {
|
||||
if let Some(term) = &n.term {
|
||||
w.write_all(b"<dt>")?;
|
||||
self.render_inlines(term, w)?;
|
||||
w.write_all(b"</dt>\n")?;
|
||||
}
|
||||
for def in &n.definitions {
|
||||
w.write_all(b"<dd>")?;
|
||||
self.render_inlines(def, w)?;
|
||||
w.write_all(b"</dd>\n")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn render_table(&self, n: &TableNode, w: &mut dyn Write) -> io::Result<()> {
|
||||
// Pre-compute per-cell rowspan/colspan + suppression so we can
|
||||
// emit valid HTML colspan/rowspan attributes instead of CSS
|
||||
// class markers. `col_span` cells get folded into the colspan
|
||||
// of the lead cell to their left; `row_span` cells get folded
|
||||
// into the rowspan of the lead cell directly above.
|
||||
let layout = table_spans(n);
|
||||
w.write_all(b"<table>\n")?;
|
||||
let mut in_thead = false;
|
||||
let mut in_tbody = false;
|
||||
for (row_idx, row) in n.rows.iter().enumerate() {
|
||||
if row.is_header {
|
||||
if !in_thead {
|
||||
w.write_all(b"<thead>\n")?;
|
||||
in_thead = true;
|
||||
}
|
||||
} else {
|
||||
if in_thead {
|
||||
w.write_all(b"</thead>\n")?;
|
||||
in_thead = false;
|
||||
}
|
||||
if !in_tbody {
|
||||
w.write_all(b"<tbody>\n")?;
|
||||
in_tbody = true;
|
||||
}
|
||||
}
|
||||
self.render_table_row(row, row_idx, &layout, &n.alignments, w)?;
|
||||
}
|
||||
if in_thead {
|
||||
w.write_all(b"</thead>\n")?;
|
||||
}
|
||||
if in_tbody {
|
||||
w.write_all(b"</tbody>\n")?;
|
||||
}
|
||||
w.write_all(b"</table>\n")
|
||||
}
|
||||
|
||||
fn render_table_row(
|
||||
&self,
|
||||
n: &TableRowNode,
|
||||
row_idx: usize,
|
||||
layout: &[Vec<CellLayout>],
|
||||
alignments: &[crate::ast::TableAlign],
|
||||
w: &mut dyn Write,
|
||||
) -> io::Result<()> {
|
||||
w.write_all(b"<tr>")?;
|
||||
for (col_idx, cell) in n.cells.iter().enumerate() {
|
||||
let info = layout
|
||||
.get(row_idx)
|
||||
.and_then(|r| r.get(col_idx))
|
||||
.copied()
|
||||
.unwrap_or(CellLayout::Lead {
|
||||
colspan: 1,
|
||||
rowspan: 1,
|
||||
});
|
||||
match info {
|
||||
CellLayout::Skip => continue,
|
||||
CellLayout::Lead { colspan, rowspan } => {
|
||||
let align = alignments
|
||||
.get(col_idx)
|
||||
.copied()
|
||||
.unwrap_or(crate::ast::TableAlign::Default);
|
||||
self.render_table_cell(cell, n.is_header, colspan, rowspan, align, w)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
w.write_all(b"</tr>\n")
|
||||
}
|
||||
|
||||
fn render_table_cell(
|
||||
&self,
|
||||
n: &TableCellNode,
|
||||
is_header: bool,
|
||||
colspan: u32,
|
||||
rowspan: u32,
|
||||
align: crate::ast::TableAlign,
|
||||
w: &mut dyn Write,
|
||||
) -> io::Result<()> {
|
||||
let tag = if is_header { "th" } else { "td" };
|
||||
write!(w, "<{tag}")?;
|
||||
if colspan > 1 {
|
||||
write!(w, " colspan=\"{colspan}\"")?;
|
||||
}
|
||||
if rowspan > 1 {
|
||||
write!(w, " rowspan=\"{rowspan}\"")?;
|
||||
}
|
||||
let align_attr = match align {
|
||||
crate::ast::TableAlign::Left => Some("left"),
|
||||
crate::ast::TableAlign::Right => Some("right"),
|
||||
crate::ast::TableAlign::Center => Some("center"),
|
||||
crate::ast::TableAlign::Default => None,
|
||||
};
|
||||
if let Some(a) = align_attr {
|
||||
write!(w, " style=\"text-align: {a};\"")?;
|
||||
}
|
||||
write!(w, ">")?;
|
||||
self.render_inlines(&n.children, w)?;
|
||||
write!(w, "</{tag}>")
|
||||
}
|
||||
|
||||
fn render_comment(&self, n: &CommentNode, w: &mut dyn Write) -> io::Result<()> {
|
||||
// Emit as an HTML comment so it survives round-trips through view-source
|
||||
// but isn't rendered to the reader. `--` inside a comment is illegal
|
||||
// in HTML, so collapse runs.
|
||||
let safe = n.content.replace("--", "- -");
|
||||
writeln!(w, "<!--{safe}-->")
|
||||
}
|
||||
|
||||
fn render_error(&self, n: &ErrorNode, w: &mut dyn Write) -> io::Result<()> {
|
||||
write!(w, "<span class=\"parse-error\" title=\"")?;
|
||||
write_escaped(&n.message, w)?;
|
||||
w.write_all(b"\">")?;
|
||||
write_escaped(&n.raw, w)?;
|
||||
w.write_all(b"</span>")
|
||||
}
|
||||
|
||||
// ===== Inline =====
|
||||
|
||||
fn render_inlines(&self, nodes: &[InlineNode], w: &mut dyn Write) -> io::Result<()> {
|
||||
for n in nodes {
|
||||
self.render_inline(n, w)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn render_inline(&self, n: &InlineNode, w: &mut dyn Write) -> io::Result<()> {
|
||||
match n {
|
||||
InlineNode::Text(t) => self.render_text(t, w),
|
||||
InlineNode::Bold(t) => self.render_bold(t, w),
|
||||
InlineNode::Italic(t) => self.render_italic(t, w),
|
||||
InlineNode::BoldItalic(t) => self.render_bold_italic(t, w),
|
||||
InlineNode::Strikethrough(t) => self.render_strikethrough(t, w),
|
||||
InlineNode::Code(t) => self.render_code(t, w),
|
||||
InlineNode::Superscript(t) => self.render_superscript(t, w),
|
||||
InlineNode::Subscript(t) => self.render_subscript(t, w),
|
||||
InlineNode::MathInline(t) => self.render_math_inline(t, w),
|
||||
InlineNode::Keyword(t) => self.render_keyword(t, w),
|
||||
InlineNode::Color(t) => self.render_color(t, w),
|
||||
InlineNode::WikiLink(t) => self.render_wikilink(t, w),
|
||||
InlineNode::ExternalLink(t) => self.render_external_link(t, w),
|
||||
InlineNode::Transclusion(t) => self.render_transclusion(t, w),
|
||||
InlineNode::RawUrl(t) => self.render_raw_url(t, w),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_text(&self, n: &TextNode, w: &mut dyn Write) -> io::Result<()> {
|
||||
write_escaped(&n.content, w)
|
||||
}
|
||||
|
||||
fn render_bold(&self, n: &BoldNode, w: &mut dyn Write) -> io::Result<()> {
|
||||
w.write_all(b"<strong>")?;
|
||||
self.render_inlines(&n.children, w)?;
|
||||
w.write_all(b"</strong>")
|
||||
}
|
||||
|
||||
fn render_italic(&self, n: &ItalicNode, w: &mut dyn Write) -> io::Result<()> {
|
||||
w.write_all(b"<em>")?;
|
||||
self.render_inlines(&n.children, w)?;
|
||||
w.write_all(b"</em>")
|
||||
}
|
||||
|
||||
fn render_bold_italic(&self, n: &BoldItalicNode, w: &mut dyn Write) -> io::Result<()> {
|
||||
w.write_all(b"<strong><em>")?;
|
||||
self.render_inlines(&n.children, w)?;
|
||||
w.write_all(b"</em></strong>")
|
||||
}
|
||||
|
||||
fn render_strikethrough(&self, n: &StrikethroughNode, w: &mut dyn Write) -> io::Result<()> {
|
||||
w.write_all(b"<del>")?;
|
||||
self.render_inlines(&n.children, w)?;
|
||||
w.write_all(b"</del>")
|
||||
}
|
||||
|
||||
fn render_code(&self, n: &CodeNode, w: &mut dyn Write) -> io::Result<()> {
|
||||
w.write_all(b"<code>")?;
|
||||
write_escaped(&n.content, w)?;
|
||||
w.write_all(b"</code>")
|
||||
}
|
||||
|
||||
fn render_superscript(&self, n: &SuperscriptNode, w: &mut dyn Write) -> io::Result<()> {
|
||||
w.write_all(b"<sup>")?;
|
||||
self.render_inlines(&n.children, w)?;
|
||||
w.write_all(b"</sup>")
|
||||
}
|
||||
|
||||
fn render_subscript(&self, n: &SubscriptNode, w: &mut dyn Write) -> io::Result<()> {
|
||||
w.write_all(b"<sub>")?;
|
||||
self.render_inlines(&n.children, w)?;
|
||||
w.write_all(b"</sub>")
|
||||
}
|
||||
|
||||
fn render_math_inline(&self, n: &MathInlineNode, w: &mut dyn Write) -> io::Result<()> {
|
||||
w.write_all(b"<span class=\"math-inline\">\\(")?;
|
||||
write_escaped(&n.content, w)?;
|
||||
w.write_all(b"\\)</span>")
|
||||
}
|
||||
|
||||
fn render_keyword(&self, n: &KeywordNode, w: &mut dyn Write) -> io::Result<()> {
|
||||
// One class per keyword so stylesheets can colour them independently
|
||||
// (e.g. red TODO/FIXME/XXX vs green DONE/FIXED/STARTED). TODO keeps the
|
||||
// vimwiki `todo` class so stock vimwiki CSS still styles it.
|
||||
let (label, class) = match n.keyword {
|
||||
Keyword::Todo => ("TODO", "todo"),
|
||||
Keyword::Done => ("DONE", "done"),
|
||||
Keyword::Started => ("STARTED", "started"),
|
||||
Keyword::Fixme => ("FIXME", "fixme"),
|
||||
Keyword::Fixed => ("FIXED", "fixed"),
|
||||
Keyword::Xxx => ("XXX", "xxx"),
|
||||
Keyword::Stopped => ("STOPPED", "stopped"),
|
||||
};
|
||||
write!(w, "<span class=\"{class}\">{label}</span>")
|
||||
}
|
||||
|
||||
fn render_color(&self, n: &ColorNode, w: &mut dyn Write) -> io::Result<()> {
|
||||
if let Some(css) = self.colors.get(&n.color) {
|
||||
w.write_all(b"<span style=\"color:")?;
|
||||
write_escaped(css, w)?;
|
||||
w.write_all(b"\">")?;
|
||||
} else {
|
||||
w.write_all(b"<span class=\"color-")?;
|
||||
write_escaped(&n.color, w)?;
|
||||
w.write_all(b"\">")?;
|
||||
}
|
||||
self.render_inlines(&n.children, w)?;
|
||||
w.write_all(b"</span>")
|
||||
}
|
||||
|
||||
fn render_wikilink(&self, n: &WikiLinkNode, w: &mut dyn Write) -> io::Result<()> {
|
||||
let href = (self.link_resolver)(&n.target);
|
||||
w.write_all(b"<a href=\"")?;
|
||||
write_escaped(&href, w)?;
|
||||
w.write_all(b"\">")?;
|
||||
match &n.description {
|
||||
Some(desc) => self.render_inlines(desc, w)?,
|
||||
None => {
|
||||
let label = n
|
||||
.target
|
||||
.path
|
||||
.clone()
|
||||
.or_else(|| n.target.anchor.clone())
|
||||
.unwrap_or_else(|| href.clone());
|
||||
write_escaped(&label, w)?;
|
||||
}
|
||||
}
|
||||
w.write_all(b"</a>")
|
||||
}
|
||||
|
||||
fn render_external_link(&self, n: &ExternalLinkNode, w: &mut dyn Write) -> io::Result<()> {
|
||||
w.write_all(b"<a href=\"")?;
|
||||
write_escaped(&n.url, w)?;
|
||||
w.write_all(b"\">")?;
|
||||
match &n.description {
|
||||
Some(desc) => self.render_inlines(desc, w)?,
|
||||
None => write_escaped(&n.url, w)?,
|
||||
}
|
||||
w.write_all(b"</a>")
|
||||
}
|
||||
|
||||
fn render_transclusion(&self, n: &TransclusionNode, w: &mut dyn Write) -> io::Result<()> {
|
||||
w.write_all(b"<img src=\"")?;
|
||||
write_escaped(&n.url, w)?;
|
||||
w.write_all(b"\" alt=\"")?;
|
||||
write_escaped(n.alt.as_deref().unwrap_or(""), w)?;
|
||||
w.write_all(b"\"")?;
|
||||
// Emit any extra attrs verbatim; both key and value are escaped.
|
||||
let mut keys: Vec<&String> = n.attrs.keys().collect();
|
||||
keys.sort();
|
||||
for k in keys {
|
||||
let v = &n.attrs[k];
|
||||
w.write_all(b" ")?;
|
||||
write_escaped(k, w)?;
|
||||
w.write_all(b"=\"")?;
|
||||
write_escaped(v, w)?;
|
||||
w.write_all(b"\"")?;
|
||||
}
|
||||
w.write_all(b">")
|
||||
}
|
||||
|
||||
fn render_raw_url(&self, n: &RawUrlNode, w: &mut dyn Write) -> io::Result<()> {
|
||||
w.write_all(b"<a href=\"")?;
|
||||
write_escaped(&n.url, w)?;
|
||||
w.write_all(b"\">")?;
|
||||
write_escaped(&n.url, w)?;
|
||||
w.write_all(b"</a>")
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Table colspan/rowspan layout =====
|
||||
|
||||
/// Per-cell render decision: emit a lead cell with the computed spans,
|
||||
/// or skip (this cell is a continuation marker absorbed by its lead).
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum CellLayout {
|
||||
Lead { colspan: u32, rowspan: u32 },
|
||||
Skip,
|
||||
}
|
||||
|
||||
/// Walk every cell in the table and resolve `col_span` / `row_span`
|
||||
/// markers into HTML `colspan="N"` / `rowspan="N"` attributes on the
|
||||
/// preceding lead cell.
|
||||
///
|
||||
/// Rules:
|
||||
/// - `col_span: true` (`>` in vimwiki source) bumps the colspan of
|
||||
/// the lead cell to its *left* in the same row.
|
||||
/// - `row_span: true` (`\\/` in vimwiki source) bumps the rowspan
|
||||
/// of the lead cell *above* in the same column.
|
||||
/// - Markers that don't have a valid lead cell (first column /
|
||||
/// first row) render as a plain empty cell so the document still
|
||||
/// parses cleanly.
|
||||
fn table_spans(table: &TableNode) -> Vec<Vec<CellLayout>> {
|
||||
let rows = table.rows.len();
|
||||
if rows == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let cols = table.rows.iter().map(|r| r.cells.len()).max().unwrap_or(0);
|
||||
let mut layout: Vec<Vec<CellLayout>> = (0..rows)
|
||||
.map(|_| {
|
||||
(0..cols)
|
||||
.map(|_| CellLayout::Lead {
|
||||
colspan: 1,
|
||||
rowspan: 1,
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
for (ri, row) in table.rows.iter().enumerate() {
|
||||
for (ci, cell) in row.cells.iter().enumerate() {
|
||||
if cell.col_span {
|
||||
// Find the lead cell to the left in this row (skipping
|
||||
// already-skipped continuation slots) and bump it.
|
||||
let mut k = ci;
|
||||
while k > 0 {
|
||||
k -= 1;
|
||||
match layout[ri][k] {
|
||||
CellLayout::Lead {
|
||||
ref mut colspan, ..
|
||||
} => {
|
||||
*colspan += 1;
|
||||
layout[ri][ci] = CellLayout::Skip;
|
||||
break;
|
||||
}
|
||||
CellLayout::Skip => continue,
|
||||
}
|
||||
}
|
||||
} else if cell.row_span {
|
||||
// Walk up in the same column for the lead cell.
|
||||
let mut k = ri;
|
||||
while k > 0 {
|
||||
k -= 1;
|
||||
match layout[k][ci] {
|
||||
CellLayout::Lead {
|
||||
ref mut rowspan, ..
|
||||
} => {
|
||||
*rowspan += 1;
|
||||
layout[ri][ci] = CellLayout::Skip;
|
||||
break;
|
||||
}
|
||||
CellLayout::Skip => continue,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
layout
|
||||
}
|
||||
|
||||
// ===== Default link resolver =====
|
||||
|
||||
/// Default `LinkResolver`. Mirrors the vimwiki link conventions:
|
||||
/// 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.
|
||||
pub fn default_link_resolver(target: &LinkTarget) -> String {
|
||||
if matches!(target.kind, LinkKind::AnchorOnly) {
|
||||
return match &target.anchor {
|
||||
Some(a) => format!("#{a}"),
|
||||
None => String::new(),
|
||||
};
|
||||
}
|
||||
|
||||
let path = target.path.as_deref().unwrap_or("");
|
||||
let mut out = String::new();
|
||||
|
||||
match target.kind {
|
||||
LinkKind::Wiki => {
|
||||
if target.is_absolute {
|
||||
out.push('/');
|
||||
}
|
||||
out.push_str(path);
|
||||
if target.is_directory {
|
||||
out.push('/');
|
||||
} else if !path.is_empty() {
|
||||
out.push_str(".html");
|
||||
}
|
||||
}
|
||||
LinkKind::Interwiki => {
|
||||
if let Some(idx) = target.wiki_index {
|
||||
out.push_str(&format!("../wiki{idx}/"));
|
||||
} else if let Some(name) = &target.wiki_name {
|
||||
out.push_str(&format!("../wn-{name}/"));
|
||||
}
|
||||
out.push_str(path);
|
||||
if !target.is_directory {
|
||||
out.push_str(".html");
|
||||
}
|
||||
}
|
||||
LinkKind::Diary => {
|
||||
out.push_str("diary/");
|
||||
out.push_str(path);
|
||||
out.push_str(".html");
|
||||
}
|
||||
LinkKind::File => {
|
||||
out.push_str(path);
|
||||
}
|
||||
LinkKind::Local => {
|
||||
out.push_str("file://");
|
||||
if target.is_absolute && !path.starts_with('/') {
|
||||
out.push('/');
|
||||
}
|
||||
out.push_str(path);
|
||||
}
|
||||
LinkKind::Raw => {
|
||||
out.push_str(path);
|
||||
}
|
||||
LinkKind::AnchorOnly => unreachable!(),
|
||||
}
|
||||
|
||||
if let Some(anchor) = &target.anchor {
|
||||
out.push('#');
|
||||
out.push_str(anchor);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// ===== HTML escaping =====
|
||||
|
||||
fn write_escaped(s: &str, w: &mut dyn Write) -> io::Result<()> {
|
||||
let bytes = s.as_bytes();
|
||||
let mut last = 0;
|
||||
for (i, &b) in bytes.iter().enumerate() {
|
||||
let replacement: &[u8] = match b {
|
||||
b'<' => b"<",
|
||||
b'>' => b">",
|
||||
b'&' => b"&",
|
||||
b'"' => b""",
|
||||
b'\'' => b"'",
|
||||
_ => continue,
|
||||
};
|
||||
if i > last {
|
||||
w.write_all(&bytes[last..i])?;
|
||||
}
|
||||
w.write_all(replacement)?;
|
||||
last = i + 1;
|
||||
}
|
||||
if last < bytes.len() {
|
||||
w.write_all(&bytes[last..])?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn escape(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'<' => out.push_str("<"),
|
||||
'>' => out.push_str(">"),
|
||||
'&' => out.push_str("&"),
|
||||
'"' => out.push_str("""),
|
||||
'\'' => out.push_str("'"),
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
//! Document renderers.
|
||||
//!
|
||||
//! A `Renderer` is writer-based, takes a `DocumentNode`, and
|
||||
//! emits its representation into any `Write`. Errors propagate through
|
||||
//! `io::Result` — there's no separate per-renderer error type so the trait
|
||||
//! stays object-safe (`Box<dyn Renderer>` is useful for the LSP later).
|
||||
|
||||
pub mod html;
|
||||
|
||||
pub use html::HtmlRenderer;
|
||||
|
||||
use std::io::{self, Write};
|
||||
|
||||
use crate::ast::DocumentNode;
|
||||
|
||||
pub trait Renderer {
|
||||
fn render(&self, doc: &DocumentNode, w: &mut dyn Write) -> io::Result<()>;
|
||||
|
||||
/// Convenience: render into a `String`. Renderers that emit non-UTF-8
|
||||
/// bytes should override or skip; the default assumes UTF-8 output.
|
||||
fn render_to_string(&self, doc: &DocumentNode) -> io::Result<String> {
|
||||
let mut buf = Vec::new();
|
||||
self.render(doc, &mut buf)?;
|
||||
String::from_utf8(buf).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
|
||||
}
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
//! Syntax plugin interface.
|
||||
//!
|
||||
//! Each syntax (vimwiki today, markdown later) implements the same shape:
|
||||
//! a `Lexer` produces a `TokenStream`, a `Parser` consumes it and produces a
|
||||
//! `DocumentNode`. A `SyntaxPlugin` is the type-erased facade that the LSP
|
||||
//! layer holds in a `SyntaxRegistry`.
|
||||
|
||||
pub mod registry;
|
||||
pub mod vimwiki;
|
||||
|
||||
pub use registry::SyntaxRegistry;
|
||||
|
||||
use crate::ast::DocumentNode;
|
||||
|
||||
/// Eager token buffer produced by a `Lexer`. The newtype keeps the door
|
||||
/// open to a lazy/streaming variant later without breaking
|
||||
/// callers.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct TokenStream<T> {
|
||||
tokens: Vec<T>,
|
||||
}
|
||||
|
||||
impl<T> TokenStream<T> {
|
||||
pub fn new() -> Self {
|
||||
Self { tokens: Vec::new() }
|
||||
}
|
||||
|
||||
pub fn from_vec(tokens: Vec<T>) -> Self {
|
||||
Self { tokens }
|
||||
}
|
||||
|
||||
pub fn into_vec(self) -> Vec<T> {
|
||||
self.tokens
|
||||
}
|
||||
|
||||
pub fn as_slice(&self) -> &[T] {
|
||||
&self.tokens
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.tokens.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.tokens.is_empty()
|
||||
}
|
||||
|
||||
pub fn iter(&self) -> std::slice::Iter<'_, T> {
|
||||
self.tokens.iter()
|
||||
}
|
||||
|
||||
pub fn push(&mut self, token: T) {
|
||||
self.tokens.push(token);
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> From<Vec<T>> for TokenStream<T> {
|
||||
fn from(tokens: Vec<T>) -> Self {
|
||||
Self::from_vec(tokens)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> IntoIterator for TokenStream<T> {
|
||||
type Item = T;
|
||||
type IntoIter = std::vec::IntoIter<T>;
|
||||
|
||||
fn into_iter(self) -> Self::IntoIter {
|
||||
self.tokens.into_iter()
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, T> IntoIterator for &'a TokenStream<T> {
|
||||
type Item = &'a T;
|
||||
type IntoIter = std::slice::Iter<'a, T>;
|
||||
|
||||
fn into_iter(self) -> Self::IntoIter {
|
||||
self.tokens.iter()
|
||||
}
|
||||
}
|
||||
|
||||
/// Lexer half of a syntax plugin. Operates on raw source text and emits a
|
||||
/// `TokenStream` whose token type is syntax-specific (e.g. `VimwikiToken`).
|
||||
pub trait Lexer {
|
||||
type Token;
|
||||
|
||||
fn lex(&self, text: &str) -> TokenStream<Self::Token>;
|
||||
}
|
||||
|
||||
/// Parser half of a syntax plugin. Consumes a `TokenStream` and produces a
|
||||
/// `DocumentNode`. Parsing is resilient — malformed input
|
||||
/// becomes `BlockNode::Error(ErrorNode)`, never a hard failure.
|
||||
pub trait Parser {
|
||||
type Token;
|
||||
|
||||
fn parse(&self, tokens: TokenStream<Self::Token>) -> DocumentNode;
|
||||
}
|
||||
|
||||
/// Type-erased syntax plugin held by the `SyntaxRegistry`.
|
||||
///
|
||||
/// Implementations typically own a `Lexer` and a `Parser` and chain them
|
||||
/// inside `parse`. The token type is deliberately hidden so the registry can
|
||||
/// hold heterogeneous plugins behind a single trait object.
|
||||
///
|
||||
/// `Send + Sync` is required — the LSP server stores plugins behind an `Arc`
|
||||
/// and shares them across request handler tasks.
|
||||
pub trait SyntaxPlugin: Send + Sync {
|
||||
fn id(&self) -> &str;
|
||||
fn display_name(&self) -> &str;
|
||||
|
||||
/// File extensions associated with this syntax. Each entry includes the
|
||||
/// leading dot, e.g. `[".wiki"]`.
|
||||
fn file_extensions(&self) -> &[&str];
|
||||
|
||||
fn parse(&self, text: &str) -> DocumentNode;
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
//! Registry of syntax plugins.
|
||||
//!
|
||||
//! Plugins are stored behind `Arc<dyn SyntaxPlugin>` so the LSP layer can
|
||||
//! hand a cheap clone to per-document tasks without re-locking the registry.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::SyntaxPlugin;
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
pub struct SyntaxRegistry {
|
||||
plugins: Vec<Arc<dyn SyntaxPlugin>>,
|
||||
}
|
||||
|
||||
impl SyntaxRegistry {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Register a plugin owned directly. The most common shape — implementer
|
||||
/// hands over a value, registry takes ownership.
|
||||
pub fn register<P>(&mut self, plugin: P)
|
||||
where
|
||||
P: SyntaxPlugin + 'static,
|
||||
{
|
||||
self.plugins.push(Arc::new(plugin));
|
||||
}
|
||||
|
||||
/// Register a pre-arc'd plugin. Useful when the same plugin instance
|
||||
/// also needs to be held elsewhere.
|
||||
pub fn register_arc(&mut self, plugin: Arc<dyn SyntaxPlugin>) {
|
||||
self.plugins.push(plugin);
|
||||
}
|
||||
|
||||
/// Look up a plugin by its `id`.
|
||||
pub fn get(&self, id: &str) -> Option<&dyn SyntaxPlugin> {
|
||||
self.plugins
|
||||
.iter()
|
||||
.find(|p| p.id() == id)
|
||||
.map(|p| p.as_ref())
|
||||
}
|
||||
|
||||
/// Look up a plugin by file extension. `ext` should include the leading
|
||||
/// dot (e.g. `".wiki"`) — matches the plugin's `file_extensions()`.
|
||||
pub fn detect_from_extension(&self, ext: &str) -> Option<&dyn SyntaxPlugin> {
|
||||
self.plugins
|
||||
.iter()
|
||||
.find(|p| p.file_extensions().iter().any(|e| *e == ext))
|
||||
.map(|p| p.as_ref())
|
||||
}
|
||||
|
||||
/// Iterate over registered plugins in registration order.
|
||||
pub fn iter(&self) -> impl Iterator<Item = &dyn SyntaxPlugin> + '_ {
|
||||
self.plugins.iter().map(|p| p.as_ref())
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.plugins.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.plugins.is_empty()
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,50 +0,0 @@
|
||||
//! Vimwiki syntax plugin.
|
||||
|
||||
pub mod lexer;
|
||||
pub mod parser;
|
||||
|
||||
pub use lexer::{VimwikiLexer, VimwikiToken, VimwikiTokenKind};
|
||||
pub use parser::VimwikiParser;
|
||||
|
||||
use crate::ast::DocumentNode;
|
||||
use crate::listsyms::ListSyms;
|
||||
use crate::syntax::{Lexer, Parser, SyntaxPlugin};
|
||||
|
||||
/// SyntaxPlugin facade: chains `VimwikiLexer` + `VimwikiParser` so the
|
||||
/// registry can hand out a single trait object.
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct VimwikiSyntax;
|
||||
|
||||
impl VimwikiSyntax {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
/// Parse with a custom checkbox palette (vimwiki's `g:vimwiki_listsyms`).
|
||||
/// The trait-level [`SyntaxPlugin::parse`] uses [`ListSyms::default`].
|
||||
pub fn parse_with_listsyms(&self, text: &str, listsyms: &ListSyms) -> DocumentNode {
|
||||
let tokens = VimwikiLexer::new()
|
||||
.with_listsyms(listsyms.clone())
|
||||
.lex(text);
|
||||
VimwikiParser::new().parse(tokens)
|
||||
}
|
||||
}
|
||||
|
||||
impl SyntaxPlugin for VimwikiSyntax {
|
||||
fn id(&self) -> &str {
|
||||
"vimwiki"
|
||||
}
|
||||
|
||||
fn display_name(&self) -> &str {
|
||||
"Vimwiki"
|
||||
}
|
||||
|
||||
fn file_extensions(&self) -> &[&str] {
|
||||
&[".wiki"]
|
||||
}
|
||||
|
||||
fn parse(&self, text: &str) -> DocumentNode {
|
||||
let tokens = VimwikiLexer::new().lex(text);
|
||||
VimwikiParser::new().parse(tokens)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,184 +0,0 @@
|
||||
//! Cluster 4 — diary frequency / period primitives.
|
||||
//! Tests the date math for ISO weeks, monthly, yearly periods.
|
||||
|
||||
use nuwiki_core::date::{DiaryDate, DiaryFrequency, DiaryPeriod};
|
||||
|
||||
#[test]
|
||||
fn frequency_parses_canonical_strings() {
|
||||
assert_eq!(DiaryFrequency::parse("daily"), DiaryFrequency::Daily);
|
||||
assert_eq!(DiaryFrequency::parse("weekly"), DiaryFrequency::Weekly);
|
||||
assert_eq!(DiaryFrequency::parse("monthly"), DiaryFrequency::Monthly);
|
||||
assert_eq!(DiaryFrequency::parse("yearly"), DiaryFrequency::Yearly);
|
||||
assert_eq!(DiaryFrequency::parse("WeEkLy"), DiaryFrequency::Weekly);
|
||||
// Unknown falls back to Daily (mirrors vimwiki's permissive behaviour).
|
||||
assert_eq!(DiaryFrequency::parse("hourly"), DiaryFrequency::Daily);
|
||||
assert_eq!(DiaryFrequency::parse(""), DiaryFrequency::Daily);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn period_format_round_trips() {
|
||||
let cases = [
|
||||
(
|
||||
"2026-05-12",
|
||||
DiaryPeriod::Day(DiaryDate::from_ymd(2026, 5, 12).unwrap()),
|
||||
),
|
||||
(
|
||||
"2026-W19",
|
||||
DiaryPeriod::Week {
|
||||
iso_year: 2026,
|
||||
iso_week: 19,
|
||||
},
|
||||
),
|
||||
(
|
||||
"2026-05",
|
||||
DiaryPeriod::Month {
|
||||
year: 2026,
|
||||
month: 5,
|
||||
},
|
||||
),
|
||||
("2026", DiaryPeriod::Year { year: 2026 }),
|
||||
];
|
||||
for (s, want) in cases {
|
||||
let parsed = DiaryPeriod::parse(s).unwrap_or_else(|| panic!("parse {s}"));
|
||||
assert_eq!(parsed, want, "parse({s})");
|
||||
assert_eq!(parsed.format(), s, "format({s})");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn period_parse_rejects_garbage() {
|
||||
assert!(DiaryPeriod::parse("garbage").is_none());
|
||||
assert!(DiaryPeriod::parse("2026-W00").is_none());
|
||||
assert!(DiaryPeriod::parse("2026-13").is_none()); // month 13
|
||||
assert!(DiaryPeriod::parse("2026-").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iso_week_jan_1_can_belong_to_prior_year() {
|
||||
// 2021-01-01 was a Friday → ISO week 53 of 2020.
|
||||
let d = DiaryDate::from_ymd(2021, 1, 1).unwrap();
|
||||
let p = DiaryPeriod::week_containing(d);
|
||||
assert_eq!(
|
||||
p,
|
||||
DiaryPeriod::Week {
|
||||
iso_year: 2020,
|
||||
iso_week: 53
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iso_week_dec_31_can_belong_to_next_year() {
|
||||
// 2024-12-30 (Monday) is in ISO week 1 of 2025.
|
||||
let d = DiaryDate::from_ymd(2024, 12, 30).unwrap();
|
||||
let p = DiaryPeriod::week_containing(d);
|
||||
assert_eq!(
|
||||
p,
|
||||
DiaryPeriod::Week {
|
||||
iso_year: 2025,
|
||||
iso_week: 1
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iso_week_mid_year_is_consistent() {
|
||||
// 2026-05-12 is a Tuesday in ISO week 20.
|
||||
let d = DiaryDate::from_ymd(2026, 5, 12).unwrap();
|
||||
let p = DiaryPeriod::week_containing(d);
|
||||
assert_eq!(
|
||||
p,
|
||||
DiaryPeriod::Week {
|
||||
iso_year: 2026,
|
||||
iso_week: 20
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn week_next_and_prev_step_by_seven_days() {
|
||||
let p = DiaryPeriod::Week {
|
||||
iso_year: 2026,
|
||||
iso_week: 20,
|
||||
};
|
||||
assert_eq!(
|
||||
p.next(),
|
||||
DiaryPeriod::Week {
|
||||
iso_year: 2026,
|
||||
iso_week: 21
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
p.prev(),
|
||||
DiaryPeriod::Week {
|
||||
iso_year: 2026,
|
||||
iso_week: 19
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn week_next_crosses_year_boundary() {
|
||||
// Week 53 of 2020 → week 53 ends; next should be week 1 of 2021.
|
||||
let p = DiaryPeriod::Week {
|
||||
iso_year: 2020,
|
||||
iso_week: 53,
|
||||
};
|
||||
assert_eq!(
|
||||
p.next(),
|
||||
DiaryPeriod::Week {
|
||||
iso_year: 2021,
|
||||
iso_week: 1
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn month_next_wraps_to_january() {
|
||||
let dec = DiaryPeriod::Month {
|
||||
year: 2026,
|
||||
month: 12,
|
||||
};
|
||||
assert_eq!(
|
||||
dec.next(),
|
||||
DiaryPeriod::Month {
|
||||
year: 2027,
|
||||
month: 1
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn month_prev_wraps_to_december() {
|
||||
let jan = DiaryPeriod::Month {
|
||||
year: 2026,
|
||||
month: 1,
|
||||
};
|
||||
assert_eq!(
|
||||
jan.prev(),
|
||||
DiaryPeriod::Month {
|
||||
year: 2025,
|
||||
month: 12
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn year_next_and_prev_increment_by_one() {
|
||||
let y = DiaryPeriod::Year { year: 2026 };
|
||||
assert_eq!(y.next(), DiaryPeriod::Year { year: 2027 });
|
||||
assert_eq!(y.prev(), DiaryPeriod::Year { year: 2025 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn day_next_and_prev_still_work() {
|
||||
let d = DiaryPeriod::Day(DiaryDate::from_ymd(2026, 5, 12).unwrap());
|
||||
assert_eq!(
|
||||
d.next(),
|
||||
DiaryPeriod::Day(DiaryDate::from_ymd(2026, 5, 13).unwrap())
|
||||
);
|
||||
assert_eq!(
|
||||
d.prev(),
|
||||
DiaryPeriod::Day(DiaryDate::from_ymd(2026, 5, 11).unwrap())
|
||||
);
|
||||
}
|
||||
@@ -1,428 +0,0 @@
|
||||
//! End-to-end tests for the HTML renderer.
|
||||
//!
|
||||
//! Pipeline: source text → `VimwikiSyntax::parse` → `HtmlRenderer::render`.
|
||||
|
||||
use nuwiki_core::ast::{
|
||||
BlockNode, DocumentNode, InlineNode, LinkTarget, Span, TableCellNode, TableNode, TableRowNode,
|
||||
TextNode,
|
||||
};
|
||||
use nuwiki_core::render::{HtmlRenderer, Renderer};
|
||||
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
|
||||
use nuwiki_core::syntax::SyntaxPlugin;
|
||||
|
||||
fn render(src: &str) -> String {
|
||||
let doc = VimwikiSyntax::new().parse(src);
|
||||
HtmlRenderer::new().render_to_string(&doc).unwrap()
|
||||
}
|
||||
|
||||
fn span_cell(text: &str, col_span: bool, row_span: bool) -> TableCellNode {
|
||||
let s = Span::default();
|
||||
TableCellNode {
|
||||
span: s,
|
||||
children: vec![InlineNode::Text(TextNode {
|
||||
span: s,
|
||||
content: text.into(),
|
||||
})],
|
||||
col_span,
|
||||
row_span,
|
||||
}
|
||||
}
|
||||
|
||||
fn span_table(rows: Vec<Vec<TableCellNode>>, has_header: bool) -> DocumentNode {
|
||||
let span = Span::default();
|
||||
let rows: Vec<TableRowNode> = rows
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, cells)| TableRowNode {
|
||||
span,
|
||||
cells,
|
||||
is_header: has_header && i == 0,
|
||||
})
|
||||
.collect();
|
||||
DocumentNode {
|
||||
span,
|
||||
metadata: Default::default(),
|
||||
children: vec![BlockNode::Table(TableNode {
|
||||
span,
|
||||
rows,
|
||||
has_header,
|
||||
alignments: Vec::new(),
|
||||
})],
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Block elements =====
|
||||
|
||||
#[test]
|
||||
fn empty_document_renders_to_empty_string() {
|
||||
assert_eq!(render(""), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heading_levels() {
|
||||
for level in 1..=6 {
|
||||
let bars = "=".repeat(level);
|
||||
let out = render(&format!("{bars} Title {bars}\n"));
|
||||
assert!(out.starts_with(&format!("<h{level}>Title</h{level}>")));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn centered_heading_gets_centered_class() {
|
||||
let out = render(" = T =\n");
|
||||
assert!(out.contains("<h1 class=\"centered\">T</h1>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraph_wraps_inline_content() {
|
||||
let out = render("Hello world\n");
|
||||
assert_eq!(out.trim(), "<p>Hello world</p>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn horizontal_rule() {
|
||||
let out = render("----\n");
|
||||
assert!(out.contains("<hr>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blockquote_lines_become_paragraphs() {
|
||||
let out = render("> first\n> second\n");
|
||||
assert!(out.contains("<blockquote>"));
|
||||
assert!(out.contains("<p>first</p>"));
|
||||
assert!(out.contains("<p>second</p>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preformatted_block_emits_pre_code_with_language_class() {
|
||||
let out = render("{{{rust\nfn main() {}\n}}}\n");
|
||||
assert!(out.contains("<pre><code class=\"language-rust\">"));
|
||||
assert!(out.contains("fn main() {}"));
|
||||
assert!(out.contains("</code></pre>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn math_block_emits_div() {
|
||||
let out = render("{{$\nx=1\n}}$\n");
|
||||
assert!(out.contains("<div class=\"math-block\">"));
|
||||
assert!(out.contains("x=1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unordered_list_with_checkbox() {
|
||||
let out = render("- [X] done\n- not done\n");
|
||||
assert!(out.contains("<ul>"));
|
||||
// vimwiki-compatible: class on the <li>, no <input> (the stylesheet draws it).
|
||||
assert!(out.contains("<li class=\"done4\">done</li>"), "{out}");
|
||||
assert!(out.contains("<li>not done</li>"), "{out}");
|
||||
assert!(!out.contains("<input"), "no input element: {out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checkbox_states_use_vimwiki_done_classes() {
|
||||
let out = render("- [ ] a\n- [.] b\n- [o] c\n- [O] d\n- [X] e\n- [-] f\n");
|
||||
assert!(out.contains("<li class=\"done0\">a</li>"), "{out}");
|
||||
assert!(out.contains("<li class=\"done1\">b</li>"), "{out}");
|
||||
assert!(out.contains("<li class=\"done2\">c</li>"), "{out}");
|
||||
assert!(out.contains("<li class=\"done3\">d</li>"), "{out}");
|
||||
assert!(out.contains("<li class=\"done4\">e</li>"), "{out}");
|
||||
assert!(out.contains("<li class=\"rejected\">f</li>"), "{out}");
|
||||
assert!(!out.contains("task-"), "no legacy task-* classes: {out}");
|
||||
assert!(!out.contains("<input"), "no input elements: {out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordered_list() {
|
||||
let out = render("1. first\n");
|
||||
assert!(out.contains("<ol>"));
|
||||
assert!(out.contains("<li>first</li>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_list_renders_recursively() {
|
||||
let out = render("- top\n - nested\n");
|
||||
assert!(out.contains("<ul>"));
|
||||
let inner_idx = out
|
||||
.find("<ul>\n<li>top")
|
||||
.map(|i| i + "<ul>\n<li>top".len())
|
||||
.unwrap_or(0);
|
||||
assert!(out[inner_idx..].contains("<ul>"));
|
||||
assert!(out.contains("nested"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn definition_list() {
|
||||
let out = render("Term:: Defn\n");
|
||||
assert!(out.contains("<dl>"));
|
||||
assert!(out.contains("<dt>Term</dt>"));
|
||||
assert!(out.contains("<dd>Defn</dd>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_with_header_separator() {
|
||||
let out = render("| a | b |\n|---|---|\n| 1 | 2 |\n");
|
||||
assert!(out.contains("<table>"));
|
||||
assert!(out.contains("<thead>"));
|
||||
assert!(out.contains("<th> a </th>"));
|
||||
assert!(out.contains("<tbody>"));
|
||||
assert!(out.contains("<td> 1 </td>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_comment_becomes_html_comment() {
|
||||
let out = render("%% hidden\n");
|
||||
assert!(out.contains("<!--"));
|
||||
assert!(out.contains("hidden"));
|
||||
assert!(out.contains("-->"));
|
||||
}
|
||||
|
||||
// ===== Inline =====
|
||||
|
||||
#[test]
|
||||
fn bold_italic_strike_super_sub() {
|
||||
let out = render("*b* _i_ ~~s~~ ^sup^ ,,sub,,\n");
|
||||
assert!(out.contains("<strong>b</strong>"));
|
||||
assert!(out.contains("<em>i</em>"));
|
||||
assert!(out.contains("<del>s</del>"));
|
||||
assert!(out.contains("<sup>sup</sup>"));
|
||||
assert!(out.contains("<sub>sub</sub>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_code_is_escaped() {
|
||||
let out = render("Use `Vec<u32>` here\n");
|
||||
assert!(out.contains("<code>Vec<u32></code>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyword_spans() {
|
||||
// One class per keyword so a stylesheet can colour groups differently.
|
||||
// TODO keeps the vimwiki `todo` class.
|
||||
let cases = [
|
||||
("TODO x\n", "<span class=\"todo\">TODO</span>"),
|
||||
("DONE x\n", "<span class=\"done\">DONE</span>"),
|
||||
("STARTED x\n", "<span class=\"started\">STARTED</span>"),
|
||||
("FIXME x\n", "<span class=\"fixme\">FIXME</span>"),
|
||||
("FIXED x\n", "<span class=\"fixed\">FIXED</span>"),
|
||||
("XXX x\n", "<span class=\"xxx\">XXX</span>"),
|
||||
("STOPPED x\n", "<span class=\"stopped\">STOPPED</span>"),
|
||||
];
|
||||
for (src, expected) in cases {
|
||||
let out = render(src);
|
||||
assert!(out.contains(expected), "src {src:?} -> {out}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_url_link() {
|
||||
let out = render("See https://example.com here\n");
|
||||
assert!(out.contains("<a href=\"https://example.com\">https://example.com</a>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wikilink_with_default_resolver() {
|
||||
let out = render("[[Page]]\n");
|
||||
assert!(out.contains("<a href=\"Page.html\">Page</a>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wikilink_with_anchor_default_resolver() {
|
||||
let out = render("[[Page#Section]]\n");
|
||||
assert!(out.contains("<a href=\"Page.html#Section\">"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anchor_only_link() {
|
||||
let out = render("[[#Section]]\n");
|
||||
assert!(out.contains("<a href=\"#Section\">"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interwiki_numbered_link() {
|
||||
let out = render("[[wiki1:Page]]\n");
|
||||
assert!(out.contains("<a href=\"../wiki1/Page.html\">"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interwiki_named_link() {
|
||||
let out = render("[[wn.Notes:Page]]\n");
|
||||
assert!(out.contains("<a href=\"../wn-Notes/Page.html\">"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diary_link() {
|
||||
let out = render("[[diary:2026-05-10]]\n");
|
||||
assert!(out.contains("<a href=\"diary/2026-05-10.html\">"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_link_routes_to_external_link_node() {
|
||||
let out = render("[[https://example.com|docs]]\n");
|
||||
assert!(out.contains("<a href=\"https://example.com\">docs</a>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transclusion_becomes_img() {
|
||||
let out = render("{{img.png|cat photo|class=hi}}\n");
|
||||
assert!(out.contains("<img src=\"img.png\""));
|
||||
assert!(out.contains("alt=\"cat photo\""));
|
||||
assert!(out.contains("class=\"hi\""));
|
||||
}
|
||||
|
||||
// ===== HTML escaping =====
|
||||
|
||||
#[test]
|
||||
fn html_special_chars_in_text_are_escaped() {
|
||||
let out = render("a < b > c & d \"e\"\n");
|
||||
assert!(out.contains("a < b > c & d "e""));
|
||||
}
|
||||
|
||||
// ===== Custom link resolver =====
|
||||
|
||||
#[test]
|
||||
fn custom_link_resolver_overrides_href() {
|
||||
let doc = VimwikiSyntax::new().parse("[[Page]]\n");
|
||||
let renderer = HtmlRenderer::new()
|
||||
.with_link_resolver(|t: &LinkTarget| format!("/wiki/{}", t.path.as_deref().unwrap_or("")));
|
||||
let out = renderer.render_to_string(&doc).unwrap();
|
||||
assert!(out.contains("<a href=\"/wiki/Page\">"));
|
||||
}
|
||||
|
||||
// ===== Template =====
|
||||
|
||||
#[test]
|
||||
fn template_substitutes_content_and_title() {
|
||||
let doc = VimwikiSyntax::new().parse("%title My Page\n= Hello =\n");
|
||||
let renderer = HtmlRenderer::new().with_template(
|
||||
"<!doctype html><html><head><title>{{title}}</title></head>\
|
||||
<body>{{content}}</body></html>",
|
||||
);
|
||||
let out = renderer.render_to_string(&doc).unwrap();
|
||||
assert!(out.contains("<title>My Page</title>"));
|
||||
assert!(out.contains("<body><h1>Hello</h1>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn template_with_missing_title_substitutes_empty_string() {
|
||||
let doc = VimwikiSyntax::new().parse("= Heading =\n");
|
||||
let renderer = HtmlRenderer::new().with_template("<title>{{title}}</title>{{content}}");
|
||||
let out = renderer.render_to_string(&doc).unwrap();
|
||||
assert!(out.contains("<title></title>"));
|
||||
assert!(out.contains("<h1>Heading</h1>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn template_substitutes_vimwiki_percent_placeholders() {
|
||||
// Stock vimwiki templates use `%title%` / `%content%` / `%root_path%`
|
||||
// rather than nuwiki's `{{…}}`. Both must resolve.
|
||||
let doc = VimwikiSyntax::new().parse("%title My Page\n= Hello =\n");
|
||||
let renderer = HtmlRenderer::new()
|
||||
.with_template(
|
||||
"<title>%title%</title><link href=\"%root_path%style.css\"><body>%content%</body>",
|
||||
)
|
||||
.with_var("root_path", "../");
|
||||
let out = renderer.render_to_string(&doc).unwrap();
|
||||
assert!(out.contains("<title>My Page</title>"), "title: {out}");
|
||||
assert!(out.contains("<body><h1>Hello</h1>"), "content: {out}");
|
||||
assert!(out.contains("href=\"../style.css\""), "root_path: {out}");
|
||||
assert!(!out.contains('%'), "no placeholder left: {out}");
|
||||
}
|
||||
|
||||
// ===== End-to-end smoke =====
|
||||
|
||||
#[test]
|
||||
fn full_document_round_trip() {
|
||||
let src = "\
|
||||
%title Smoke
|
||||
= Heading =
|
||||
|
||||
Paragraph with *bold* and _italic_ and `code`.
|
||||
|
||||
- one
|
||||
- two
|
||||
|
||||
> quoted
|
||||
|
||||
----
|
||||
|
||||
| a | b |
|
||||
|---|---|
|
||||
| 1 | 2 |
|
||||
|
||||
{{{rust
|
||||
fn x() {}
|
||||
}}}
|
||||
";
|
||||
let doc = VimwikiSyntax::new().parse(src);
|
||||
let out = HtmlRenderer::new().render_to_string(&doc).unwrap();
|
||||
// A few sanity checks; the full output is exercised piece-by-piece above.
|
||||
for needle in [
|
||||
"<h1>Heading</h1>",
|
||||
"<strong>bold</strong>",
|
||||
"<em>italic</em>",
|
||||
"<code>code</code>",
|
||||
"<ul>",
|
||||
"<blockquote>",
|
||||
"<hr>",
|
||||
"<table>",
|
||||
"<pre><code class=\"language-rust\">",
|
||||
] {
|
||||
assert!(
|
||||
out.contains(needle),
|
||||
"expected {needle:?} in output:\n{out}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Table colspan / rowspan =====
|
||||
|
||||
#[test]
|
||||
fn colspan_marker_folds_into_lead_cell() {
|
||||
// | a | b | c |
|
||||
// | d | > | e | → second row has b spanning 2 cols (cell index 1 is >)
|
||||
let doc = span_table(
|
||||
vec![
|
||||
vec![
|
||||
span_cell("a", false, false),
|
||||
span_cell("b", false, false),
|
||||
span_cell("c", false, false),
|
||||
],
|
||||
vec![
|
||||
span_cell("d", false, false),
|
||||
span_cell(">", true, false),
|
||||
span_cell("e", false, false),
|
||||
],
|
||||
],
|
||||
false,
|
||||
);
|
||||
let out = HtmlRenderer::new().render_to_string(&doc).unwrap();
|
||||
assert!(out.contains(r#"<td colspan="2">d</td>"#), "got: {out}");
|
||||
assert!(!out.contains(r#"class="col-span""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rowspan_marker_folds_into_lead_cell_above() {
|
||||
let doc = span_table(
|
||||
vec![
|
||||
vec![span_cell("a", false, false), span_cell("b", false, false)],
|
||||
vec![span_cell("c", false, false), span_cell("\\/", false, true)],
|
||||
],
|
||||
false,
|
||||
);
|
||||
let out = HtmlRenderer::new().render_to_string(&doc).unwrap();
|
||||
assert!(out.contains(r#"<td rowspan="2">b</td>"#), "got: {out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_spans_emits_no_table_attrs() {
|
||||
let doc = span_table(
|
||||
vec![
|
||||
vec![span_cell("a", false, false), span_cell("b", false, false)],
|
||||
vec![span_cell("c", false, false), span_cell("d", false, false)],
|
||||
],
|
||||
false,
|
||||
);
|
||||
let out = HtmlRenderer::new().render_to_string(&doc).unwrap();
|
||||
assert!(!out.contains("colspan="));
|
||||
assert!(!out.contains("rowspan="));
|
||||
}
|
||||
@@ -1,639 +0,0 @@
|
||||
//! End-to-end tests for the vimwiki lexer.
|
||||
//!
|
||||
//! Each test feeds a small fragment to `VimwikiLexer::lex` and asserts the
|
||||
//! exact token-kind sequence. A separate `spans_are_byte_accurate` test
|
||||
//! pins down position semantics.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use nuwiki_core::ast::{CheckboxState, Keyword, ListSymbol, Position};
|
||||
use nuwiki_core::syntax::vimwiki::{VimwikiLexer, VimwikiTokenKind};
|
||||
use nuwiki_core::syntax::Lexer;
|
||||
|
||||
use VimwikiTokenKind::*;
|
||||
|
||||
fn lex(text: &str) -> Vec<VimwikiTokenKind> {
|
||||
VimwikiLexer::new()
|
||||
.lex(text)
|
||||
.into_iter()
|
||||
.map(|t| t.kind)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn text(s: &str) -> VimwikiTokenKind {
|
||||
Text(s.into())
|
||||
}
|
||||
|
||||
// ===== Empty / whitespace =====
|
||||
|
||||
#[test]
|
||||
fn empty_input_produces_no_tokens() {
|
||||
assert!(lex("").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lone_newline_is_a_blank_line() {
|
||||
assert_eq!(lex("\n"), vec![BlankLine]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whitespace_only_line_is_a_blank_line() {
|
||||
assert_eq!(lex(" \n"), vec![BlankLine]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blank_lines_separate_paragraphs() {
|
||||
assert_eq!(
|
||||
lex("hello\n\nworld\n"),
|
||||
vec![text("hello"), Newline, BlankLine, text("world"), Newline]
|
||||
);
|
||||
}
|
||||
|
||||
// ===== Headings =====
|
||||
|
||||
#[test]
|
||||
fn headings_at_all_six_levels() {
|
||||
let mut tokens = Vec::new();
|
||||
for level in 1..=6 {
|
||||
let line = format!(
|
||||
"{} L{} {}\n",
|
||||
"=".repeat(level as usize),
|
||||
level,
|
||||
"=".repeat(level as usize)
|
||||
);
|
||||
let lexed = lex(&line);
|
||||
assert!(matches!(
|
||||
lexed[0],
|
||||
HeadingOpen { level: l, centered: false } if l == level
|
||||
));
|
||||
tokens.extend(lexed);
|
||||
}
|
||||
assert!(tokens.iter().any(|t| matches!(t, HeadingClose)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn centered_heading_is_marked_centered() {
|
||||
let lexed = lex(" = title =\n");
|
||||
assert!(matches!(
|
||||
lexed[0],
|
||||
HeadingOpen {
|
||||
level: 1,
|
||||
centered: true
|
||||
}
|
||||
));
|
||||
assert_eq!(lexed[1], text("title"));
|
||||
assert!(matches!(lexed[2], HeadingClose));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heading_with_inline_bold() {
|
||||
let lexed = lex("== Hello *world* ==\n");
|
||||
assert_eq!(
|
||||
lexed,
|
||||
vec![
|
||||
HeadingOpen {
|
||||
level: 2,
|
||||
centered: false
|
||||
},
|
||||
text("Hello "),
|
||||
BoldDelim,
|
||||
text("world"),
|
||||
BoldDelim,
|
||||
HeadingClose,
|
||||
Newline,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
// ===== Horizontal rule =====
|
||||
|
||||
#[test]
|
||||
fn horizontal_rule_requires_four_or_more_dashes() {
|
||||
assert_eq!(lex("----\n"), vec![HorizontalRule, Newline]);
|
||||
assert_eq!(lex("--------\n"), vec![HorizontalRule, Newline]);
|
||||
// Three dashes: not a rule, parsed as text.
|
||||
assert_eq!(lex("---\n"), vec![text("---"), Newline]);
|
||||
}
|
||||
|
||||
// ===== Comments =====
|
||||
|
||||
#[test]
|
||||
fn single_line_comment() {
|
||||
assert_eq!(
|
||||
lex("%% hello\n"),
|
||||
vec![CommentLine(" hello".into()), Newline]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiline_comment_spans_multiple_lines() {
|
||||
assert_eq!(
|
||||
lex("%%+ a\nb\n+%%\n"),
|
||||
vec![
|
||||
CommentMultiOpen,
|
||||
CommentMultiLine(" a".into()),
|
||||
Newline,
|
||||
CommentMultiLine("b".into()),
|
||||
Newline,
|
||||
CommentMultiClose,
|
||||
Newline,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiline_comment_one_liner() {
|
||||
assert_eq!(
|
||||
lex("%%+ inline +%%\n"),
|
||||
vec![
|
||||
CommentMultiOpen,
|
||||
CommentMultiLine(" inline ".into()),
|
||||
CommentMultiClose,
|
||||
Newline,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
// ===== Preformatted =====
|
||||
|
||||
#[test]
|
||||
fn preformatted_block_captures_lines_verbatim() {
|
||||
let lexed = lex("{{{\nfn main() {}\n}}}\n");
|
||||
assert!(matches!(lexed[0], PreformattedOpen { .. }));
|
||||
assert_eq!(lexed[1], Newline);
|
||||
assert_eq!(lexed[2], PreformattedLine("fn main() {}".into()));
|
||||
assert_eq!(lexed[3], Newline);
|
||||
assert_eq!(lexed[4], PreformattedClose);
|
||||
assert_eq!(lexed[5], Newline);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preformatted_block_parses_language() {
|
||||
let lexed = lex("{{{rust\nx\n}}}\n");
|
||||
let PreformattedOpen { language, .. } = &lexed[0] else {
|
||||
panic!("expected PreformattedOpen");
|
||||
};
|
||||
assert_eq!(language.as_deref(), Some("rust"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preformatted_block_parses_attrs() {
|
||||
let lexed = lex("{{{rust class=\"hl\" key=val\nx\n}}}\n");
|
||||
let PreformattedOpen { language, attrs } = &lexed[0] else {
|
||||
panic!("expected PreformattedOpen");
|
||||
};
|
||||
assert_eq!(language.as_deref(), Some("rust"));
|
||||
let mut expected = HashMap::new();
|
||||
expected.insert("class".into(), "hl".into());
|
||||
expected.insert("key".into(), "val".into());
|
||||
assert_eq!(attrs, &expected);
|
||||
}
|
||||
|
||||
// ===== Math block =====
|
||||
|
||||
#[test]
|
||||
fn math_block_captures_environment() {
|
||||
let lexed = lex("{{$%align%\nx = 1\n}}$\n");
|
||||
assert_eq!(
|
||||
lexed[0],
|
||||
MathBlockOpen {
|
||||
environment: Some("align".into())
|
||||
}
|
||||
);
|
||||
assert_eq!(lexed[2], MathBlockLine("x = 1".into()));
|
||||
assert_eq!(lexed[4], MathBlockClose);
|
||||
}
|
||||
|
||||
// ===== Placeholders =====
|
||||
|
||||
#[test]
|
||||
fn all_four_placeholders() {
|
||||
assert_eq!(
|
||||
lex("%title My Page\n"),
|
||||
vec![PlaceholderTitle(Some("My Page".into())), Newline]
|
||||
);
|
||||
assert_eq!(lex("%nohtml\n"), vec![PlaceholderNohtml, Newline]);
|
||||
assert_eq!(
|
||||
lex("%template fancy\n"),
|
||||
vec![PlaceholderTemplate(Some("fancy".into())), Newline]
|
||||
);
|
||||
assert_eq!(
|
||||
lex("%date 2026-05-10\n"),
|
||||
vec![PlaceholderDate(Some("2026-05-10".into())), Newline]
|
||||
);
|
||||
}
|
||||
|
||||
// ===== Lists =====
|
||||
|
||||
#[test]
|
||||
fn list_marker_dash() {
|
||||
let lexed = lex("- item\n");
|
||||
assert_eq!(
|
||||
lexed,
|
||||
vec![
|
||||
ListMarker {
|
||||
symbol: ListSymbol::Dash,
|
||||
indent: 0
|
||||
},
|
||||
text("item"),
|
||||
Newline,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_marker_star_with_space_is_a_list_not_bold() {
|
||||
let lexed = lex("* item\n");
|
||||
assert_eq!(
|
||||
lexed[0],
|
||||
ListMarker {
|
||||
symbol: ListSymbol::Star,
|
||||
indent: 0
|
||||
}
|
||||
);
|
||||
assert_eq!(lexed[1], text("item"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_marker_kinds() {
|
||||
let cases: &[(&str, ListSymbol)] = &[
|
||||
("- a\n", ListSymbol::Dash),
|
||||
("* a\n", ListSymbol::Star),
|
||||
("# a\n", ListSymbol::Hash),
|
||||
("1. a\n", ListSymbol::Numeric),
|
||||
("1) a\n", ListSymbol::NumericParen),
|
||||
("a) a\n", ListSymbol::AlphaParen),
|
||||
("A) a\n", ListSymbol::AlphaUpperParen),
|
||||
("i) a\n", ListSymbol::RomanParen),
|
||||
("I) a\n", ListSymbol::RomanUpperParen),
|
||||
];
|
||||
for (line, expected) in cases {
|
||||
let lexed = lex(line);
|
||||
assert!(
|
||||
matches!(lexed[0], ListMarker { symbol, .. } if symbol == *expected),
|
||||
"input {line:?} expected {expected:?}, got {:?}",
|
||||
lexed[0]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_marker_at_indent() {
|
||||
let lexed = lex(" - nested\n");
|
||||
assert_eq!(
|
||||
lexed[0],
|
||||
ListMarker {
|
||||
symbol: ListSymbol::Dash,
|
||||
indent: 4
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checkbox_states() {
|
||||
let cases: &[(&str, CheckboxState)] = &[
|
||||
("- [ ] a\n", CheckboxState::Empty),
|
||||
("- [.] a\n", CheckboxState::Quarter),
|
||||
("- [o] a\n", CheckboxState::Half),
|
||||
("- [O] a\n", CheckboxState::ThreeQuarters),
|
||||
("- [X] a\n", CheckboxState::Done),
|
||||
("- [-] a\n", CheckboxState::Rejected),
|
||||
];
|
||||
for (line, expected) in cases {
|
||||
let lexed = lex(line);
|
||||
assert!(
|
||||
matches!(lexed[1], Checkbox(s) if s == *expected),
|
||||
"input {line:?} expected {expected:?}, got {:?}",
|
||||
lexed[1]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checkbox_states_honor_custom_palette() {
|
||||
use nuwiki_core::listsyms::ListSyms;
|
||||
|
||||
let lex_with = |text: &str, syms: ListSyms| -> Vec<VimwikiTokenKind> {
|
||||
VimwikiLexer::new()
|
||||
.with_listsyms(syms)
|
||||
.lex(text)
|
||||
.into_iter()
|
||||
.map(|t| t.kind)
|
||||
.collect()
|
||||
};
|
||||
|
||||
// 3-glyph palette ` x✓`: empty / half / done, plus the fixed rejected `-`.
|
||||
let syms = ListSyms::new(" x✓");
|
||||
let cases: &[(&str, CheckboxState)] = &[
|
||||
("- [ ] a\n", CheckboxState::Empty),
|
||||
("- [x] a\n", CheckboxState::Half),
|
||||
("- [✓] a\n", CheckboxState::Done),
|
||||
("- [-] a\n", CheckboxState::Rejected),
|
||||
];
|
||||
for (line, expected) in cases {
|
||||
let lexed = lex_with(line, syms.clone());
|
||||
assert!(
|
||||
matches!(lexed[1], Checkbox(s) if s == *expected),
|
||||
"input {line:?} expected {expected:?}, got {:?}",
|
||||
lexed[1]
|
||||
);
|
||||
}
|
||||
|
||||
// Default-palette glyphs are not checkboxes under this palette: `[o]`
|
||||
// is plain inline text, so no Checkbox token is emitted.
|
||||
let lexed = lex_with("- [o] a\n", syms);
|
||||
assert!(
|
||||
!lexed.iter().any(|k| matches!(k, Checkbox(_))),
|
||||
"expected no checkbox token, got {lexed:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// ===== Blockquotes =====
|
||||
|
||||
#[test]
|
||||
fn angle_blockquote() {
|
||||
assert_eq!(
|
||||
lex("> quoted\n"),
|
||||
vec![BlockquoteMarker, text("quoted"), Newline]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indent_blockquote() {
|
||||
assert_eq!(
|
||||
lex(" quoted\n"),
|
||||
vec![BlockquoteIndent, text("quoted"), Newline]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indent_with_list_marker_is_a_list_not_blockquote() {
|
||||
let lexed = lex(" - item\n");
|
||||
assert!(matches!(lexed[0], ListMarker { indent: 4, .. }));
|
||||
}
|
||||
|
||||
// ===== Tables =====
|
||||
|
||||
#[test]
|
||||
fn simple_table_row() {
|
||||
assert_eq!(
|
||||
lex("| a | b |\n"),
|
||||
vec![
|
||||
TableSep,
|
||||
text(" a "),
|
||||
TableSep,
|
||||
text(" b "),
|
||||
TableSep,
|
||||
Newline,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_header_separator_row() {
|
||||
use nuwiki_core::ast::TableAlign;
|
||||
assert_eq!(
|
||||
lex("|---|---|\n"),
|
||||
vec![
|
||||
TableHeaderRow(vec![TableAlign::Default, TableAlign::Default]),
|
||||
Newline
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_col_and_row_span_cells() {
|
||||
let lexed = lex("| a | > | \\/ |\n");
|
||||
// sep, " a ", sep, ColSpan, sep, RowSpan, sep, newline
|
||||
assert_eq!(
|
||||
lexed,
|
||||
vec![
|
||||
TableSep,
|
||||
text(" a "),
|
||||
TableSep,
|
||||
TableColSpan,
|
||||
TableSep,
|
||||
TableRowSpan,
|
||||
TableSep,
|
||||
Newline,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
// ===== Definition list =====
|
||||
|
||||
#[test]
|
||||
fn definition_term_with_inline_definition() {
|
||||
let lexed = lex("Term:: Definition\n");
|
||||
assert_eq!(
|
||||
lexed,
|
||||
vec![
|
||||
text("Term"),
|
||||
DefinitionTermMarker,
|
||||
text("Definition"),
|
||||
Newline,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn definition_term_alone() {
|
||||
let lexed = lex("Term::\n");
|
||||
assert_eq!(lexed, vec![text("Term"), DefinitionTermMarker, Newline]);
|
||||
}
|
||||
|
||||
// ===== Inline typefaces =====
|
||||
|
||||
#[test]
|
||||
fn inline_bold_italic_strike_super_sub() {
|
||||
assert_eq!(
|
||||
lex("*b* _i_ ~~s~~ ^sup^ ,,sub,,\n"),
|
||||
vec![
|
||||
BoldDelim,
|
||||
text("b"),
|
||||
BoldDelim,
|
||||
text(" "),
|
||||
ItalicDelim,
|
||||
text("i"),
|
||||
ItalicDelim,
|
||||
text(" "),
|
||||
StrikethroughDelim,
|
||||
text("s"),
|
||||
StrikethroughDelim,
|
||||
text(" "),
|
||||
SuperscriptDelim,
|
||||
text("sup"),
|
||||
SuperscriptDelim,
|
||||
text(" "),
|
||||
SubscriptDelim,
|
||||
text("sub"),
|
||||
SubscriptDelim,
|
||||
Newline,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_code_captures_content() {
|
||||
assert_eq!(lex("`x = 1`\n"), vec![Code("x = 1".into()), Newline]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_math_captures_content() {
|
||||
assert_eq!(
|
||||
lex("$E = mc^2$\n"),
|
||||
vec![MathInline("E = mc^2".into()), Newline]
|
||||
);
|
||||
}
|
||||
|
||||
// ===== Keywords =====
|
||||
|
||||
#[test]
|
||||
fn all_keywords() {
|
||||
let cases: &[(&str, Keyword)] = &[
|
||||
("TODO", Keyword::Todo),
|
||||
("DONE", Keyword::Done),
|
||||
("STARTED", Keyword::Started),
|
||||
("FIXME", Keyword::Fixme),
|
||||
("FIXED", Keyword::Fixed),
|
||||
("XXX", Keyword::Xxx),
|
||||
("STOPPED", Keyword::Stopped),
|
||||
];
|
||||
for (s, expected) in cases {
|
||||
let line = format!("{s} stuff\n");
|
||||
let lexed = lex(&line);
|
||||
assert!(
|
||||
matches!(lexed[0], Keyword(k) if k == *expected),
|
||||
"input {s:?} expected {expected:?}, got {:?}",
|
||||
lexed[0]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyword_only_at_word_boundary() {
|
||||
// Embedded TODO inside a longer word should NOT match as a keyword.
|
||||
let lexed = lex("aTODOb\n");
|
||||
assert_eq!(lexed, vec![text("aTODOb"), Newline]);
|
||||
}
|
||||
|
||||
// ===== Wikilinks / transclusions / raw URLs =====
|
||||
|
||||
#[test]
|
||||
fn bare_wikilink() {
|
||||
assert_eq!(
|
||||
lex("[[Page]]\n"),
|
||||
vec![WikiLinkOpen, text("Page"), WikiLinkClose, Newline]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn described_wikilink() {
|
||||
assert_eq!(
|
||||
lex("[[Page|Description]]\n"),
|
||||
vec![
|
||||
WikiLinkOpen,
|
||||
text("Page"),
|
||||
WikiLinkSep,
|
||||
text("Description"),
|
||||
WikiLinkClose,
|
||||
Newline,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wikilink_with_anchor_is_lexed_as_text() {
|
||||
// The lexer doesn't carve out anchors; the parser does.
|
||||
let lexed = lex("[[Page#Anchor]]\n");
|
||||
assert_eq!(
|
||||
lexed,
|
||||
vec![WikiLinkOpen, text("Page#Anchor"), WikiLinkClose, Newline,]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transclusion_with_alt_and_attrs() {
|
||||
assert_eq!(
|
||||
lex("{{img.png|alt|class=hi}}\n"),
|
||||
vec![
|
||||
TransclusionOpen,
|
||||
text("img.png"),
|
||||
TransclusionSep,
|
||||
text("alt"),
|
||||
TransclusionSep,
|
||||
text("class=hi"),
|
||||
TransclusionClose,
|
||||
Newline,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_urls_for_each_supported_scheme() {
|
||||
for scheme in &[
|
||||
"https://example.com",
|
||||
"http://example.com/path?q=1",
|
||||
"ftp://archive.example.com/file.tar.gz",
|
||||
"mailto:hi@example.com",
|
||||
"file:///tmp/file.txt",
|
||||
] {
|
||||
let line = format!("see {scheme} now\n");
|
||||
let lexed = lex(&line);
|
||||
assert!(
|
||||
lexed.iter().any(|t| matches!(t, RawUrl(u) if u == scheme)),
|
||||
"scheme {scheme:?} not lexed; got {lexed:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_url_drops_trailing_sentence_punctuation() {
|
||||
let lexed = lex("Visit https://example.com.\n");
|
||||
assert!(
|
||||
lexed
|
||||
.iter()
|
||||
.any(|t| matches!(t, RawUrl(u) if u == "https://example.com")),
|
||||
"got {lexed:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// ===== Span correctness =====
|
||||
|
||||
#[test]
|
||||
fn spans_are_byte_accurate() {
|
||||
// Input layout:
|
||||
// Line 0: "= H ="
|
||||
// Line 1: "" (blank line)
|
||||
// Line 2: "*bold*"
|
||||
let src = "= H =\n\n*bold*\n";
|
||||
let tokens = VimwikiLexer::new().lex(src).into_vec();
|
||||
|
||||
// First token: HeadingOpen at line 0, col 0..1
|
||||
assert_eq!(
|
||||
tokens[0].span.start,
|
||||
Position {
|
||||
line: 0,
|
||||
column: 0,
|
||||
offset: 0
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
tokens[0].span.end,
|
||||
Position {
|
||||
line: 0,
|
||||
column: 1,
|
||||
offset: 1
|
||||
}
|
||||
);
|
||||
|
||||
// Find the BoldDelim opening on line 2.
|
||||
let bold_open = tokens
|
||||
.iter()
|
||||
.find(|t| matches!(t.kind, BoldDelim))
|
||||
.expect("bold delim");
|
||||
assert_eq!(bold_open.span.start.line, 2);
|
||||
assert_eq!(bold_open.span.start.column, 0);
|
||||
// "= H =\n" = 6 bytes, "\n" = 1, so line 2 starts at offset 7.
|
||||
assert_eq!(bold_open.span.start.offset, 7);
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
//! Cluster 8b — multi-line list-item continuation.
|
||||
//!
|
||||
//! Lines indented strictly past the item's marker attach to the item
|
||||
//! rather than becoming a sibling paragraph. Mirrors upstream vimwiki
|
||||
//! + Markdown.
|
||||
|
||||
use nuwiki_core::ast::{BlockNode, InlineNode};
|
||||
use nuwiki_core::render::{HtmlRenderer, Renderer};
|
||||
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
|
||||
use nuwiki_core::syntax::SyntaxPlugin;
|
||||
|
||||
fn parse(src: &str) -> nuwiki_core::ast::DocumentNode {
|
||||
VimwikiSyntax::new().parse(src)
|
||||
}
|
||||
|
||||
fn single_list(doc: &nuwiki_core::ast::DocumentNode) -> &nuwiki_core::ast::ListNode {
|
||||
match &doc.children[0] {
|
||||
BlockNode::List(l) => l,
|
||||
other => panic!("expected list, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn text_of(inlines: &[InlineNode]) -> String {
|
||||
let mut out = String::new();
|
||||
for n in inlines {
|
||||
match n {
|
||||
InlineNode::Text(t) => out.push_str(&t.content),
|
||||
InlineNode::Bold(b) => out.push_str(&text_of(&b.children)),
|
||||
InlineNode::Italic(i) => out.push_str(&text_of(&i.children)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indented_line_after_marker_continues_the_item() {
|
||||
let doc = parse("- first item\n continuing here\n- second\n");
|
||||
assert_eq!(doc.children.len(), 1, "should be a single list block");
|
||||
let list = single_list(&doc);
|
||||
assert_eq!(list.items.len(), 2);
|
||||
let first = &list.items[0];
|
||||
let joined = text_of(&first.children);
|
||||
assert_eq!(joined, "first item continuing here");
|
||||
let second = &list.items[1];
|
||||
assert_eq!(text_of(&second.children), "second");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_continuation_lines_both_attach() {
|
||||
let doc = parse("- top\n middle\n bottom\n");
|
||||
let list = single_list(&doc);
|
||||
assert_eq!(list.items.len(), 1);
|
||||
let joined = text_of(&list.items[0].children);
|
||||
assert_eq!(joined, "top middle bottom");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unindented_line_breaks_the_list() {
|
||||
let doc = parse("- item\nflush against margin\n");
|
||||
// Two blocks: a list (just `- item`) and a paragraph.
|
||||
assert!(
|
||||
doc.children.len() >= 2,
|
||||
"want list + paragraph, got {}",
|
||||
doc.children.len()
|
||||
);
|
||||
match &doc.children[0] {
|
||||
BlockNode::List(l) => {
|
||||
assert_eq!(text_of(&l.items[0].children), "item");
|
||||
}
|
||||
other => panic!("expected list first, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blank_line_breaks_the_list_item() {
|
||||
// A blank line ends the current list (paragraph semantics).
|
||||
let doc = parse("- item\n\n orphaned\n");
|
||||
let list = single_list(&doc);
|
||||
assert_eq!(list.items.len(), 1);
|
||||
assert_eq!(text_of(&list.items[0].children), "item");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn continuation_works_for_nested_lists_too() {
|
||||
let doc = parse("- parent\n - child\n continued child\n- sibling\n");
|
||||
let list = single_list(&doc);
|
||||
assert_eq!(list.items.len(), 2);
|
||||
let parent = &list.items[0];
|
||||
let sublist = parent.sublist.as_ref().expect("nested list");
|
||||
assert_eq!(sublist.items.len(), 1);
|
||||
let child = &sublist.items[0];
|
||||
assert_eq!(text_of(&child.children), "child continued child");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn html_renderer_emits_a_single_li_per_item() {
|
||||
let doc = parse("- first item\n continuing here\n- second\n");
|
||||
let out = HtmlRenderer::new().render_to_string(&doc).unwrap();
|
||||
// Single <ul>, two <li> entries — the bug we're fixing produced
|
||||
// two adjacent <ul> elements with an interleaved <p>.
|
||||
assert_eq!(out.matches("<ul>").count(), 1, "got: {out}");
|
||||
assert_eq!(out.matches("<li>").count(), 2, "got: {out}");
|
||||
assert!(!out.contains("<p>"), "no orphan paragraph; got: {out}");
|
||||
assert!(out.contains("first item continuing here"), "got: {out}");
|
||||
}
|
||||
@@ -1,591 +0,0 @@
|
||||
//! End-to-end tests for the vimwiki parser. Each test runs source text
|
||||
//! through the lexer + parser and asserts on the resulting AST.
|
||||
|
||||
use nuwiki_core::ast::{BlockNode, CheckboxState, InlineNode, Keyword, LinkKind, ListSymbol};
|
||||
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
|
||||
use nuwiki_core::syntax::SyntaxPlugin;
|
||||
|
||||
fn parse(text: &str) -> nuwiki_core::ast::DocumentNode {
|
||||
VimwikiSyntax::new().parse(text)
|
||||
}
|
||||
|
||||
// ===== Document scaffolding =====
|
||||
|
||||
#[test]
|
||||
fn empty_input_yields_empty_document() {
|
||||
let doc = parse("");
|
||||
assert!(doc.children.is_empty());
|
||||
assert_eq!(doc.metadata.title, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placeholders_populate_metadata() {
|
||||
let doc = parse("%title My Page\n%nohtml\n%template fancy\n%date 2026-05-10\n");
|
||||
assert_eq!(doc.metadata.title.as_deref(), Some("My Page"));
|
||||
assert!(doc.metadata.nohtml);
|
||||
assert_eq!(doc.metadata.template.as_deref(), Some("fancy"));
|
||||
assert_eq!(doc.metadata.date.as_deref(), Some("2026-05-10"));
|
||||
// No body blocks.
|
||||
assert!(doc.children.is_empty());
|
||||
}
|
||||
|
||||
// ===== Headings =====
|
||||
|
||||
#[test]
|
||||
fn heading_each_level() {
|
||||
for level in 1..=6 {
|
||||
let line = format!(
|
||||
"{} L{} {}\n",
|
||||
"=".repeat(level as usize),
|
||||
level,
|
||||
"=".repeat(level as usize)
|
||||
);
|
||||
let doc = parse(&line);
|
||||
let BlockNode::Heading(h) = &doc.children[0] else {
|
||||
panic!("expected heading at level {level}");
|
||||
};
|
||||
assert_eq!(h.level, level);
|
||||
assert!(!h.centered);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn centered_heading_flag() {
|
||||
let doc = parse(" = title =\n");
|
||||
let BlockNode::Heading(h) = &doc.children[0] else {
|
||||
panic!("expected heading");
|
||||
};
|
||||
assert!(h.centered);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heading_inline_bold() {
|
||||
let doc = parse("== Hello *world* ==\n");
|
||||
let BlockNode::Heading(h) = &doc.children[0] else {
|
||||
panic!("expected heading");
|
||||
};
|
||||
// Children: Text("Hello "), Bold(Text("world"))
|
||||
assert_eq!(h.children.len(), 2);
|
||||
assert!(matches!(&h.children[0], InlineNode::Text(t) if t.content == "Hello "));
|
||||
let InlineNode::Bold(b) = &h.children[1] else {
|
||||
panic!("expected Bold");
|
||||
};
|
||||
assert!(matches!(&b.children[0], InlineNode::Text(t) if t.content == "world"));
|
||||
}
|
||||
|
||||
// ===== Paragraphs =====
|
||||
|
||||
#[test]
|
||||
fn paragraph_simple_text() {
|
||||
let doc = parse("Hello world\n");
|
||||
let BlockNode::Paragraph(p) = &doc.children[0] else {
|
||||
panic!("expected paragraph");
|
||||
};
|
||||
assert_eq!(p.children.len(), 1);
|
||||
assert!(matches!(&p.children[0], InlineNode::Text(t) if t.content == "Hello world"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraph_spans_multiple_lines() {
|
||||
let doc = parse("Hello\nworld\n");
|
||||
let BlockNode::Paragraph(p) = &doc.children[0] else {
|
||||
panic!("expected paragraph");
|
||||
};
|
||||
// Contents: Text("Hello"), Text(" ") (from soft newline), Text("world")
|
||||
let text_concat: String = p
|
||||
.children
|
||||
.iter()
|
||||
.filter_map(|n| match n {
|
||||
InlineNode::Text(t) => Some(t.content.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(text_concat, "Hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blank_line_breaks_paragraph() {
|
||||
let doc = parse("first\n\nsecond\n");
|
||||
assert_eq!(doc.children.len(), 2);
|
||||
assert!(matches!(doc.children[0], BlockNode::Paragraph(_)));
|
||||
assert!(matches!(doc.children[1], BlockNode::Paragraph(_)));
|
||||
}
|
||||
|
||||
// ===== Horizontal rule =====
|
||||
|
||||
#[test]
|
||||
fn horizontal_rule() {
|
||||
let doc = parse("----\n");
|
||||
assert!(matches!(doc.children[0], BlockNode::HorizontalRule(_)));
|
||||
}
|
||||
|
||||
// ===== Lists =====
|
||||
|
||||
#[test]
|
||||
fn flat_unordered_list() {
|
||||
let doc = parse("- one\n- two\n- three\n");
|
||||
let BlockNode::List(list) = &doc.children[0] else {
|
||||
panic!("expected list");
|
||||
};
|
||||
assert!(!list.ordered);
|
||||
assert_eq!(list.symbol, ListSymbol::Dash);
|
||||
assert_eq!(list.items.len(), 3);
|
||||
for (item, expected) in list.items.iter().zip(["one", "two", "three"]) {
|
||||
assert!(matches!(&item.children[0], InlineNode::Text(t) if t.content == expected));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordered_list_each_marker_kind() {
|
||||
let cases: &[(&str, ListSymbol, bool)] = &[
|
||||
("- a\n", ListSymbol::Dash, false),
|
||||
("* a\n", ListSymbol::Star, false),
|
||||
("# a\n", ListSymbol::Hash, false),
|
||||
("1. a\n", ListSymbol::Numeric, true),
|
||||
("1) a\n", ListSymbol::NumericParen, true),
|
||||
("a) a\n", ListSymbol::AlphaParen, true),
|
||||
("A) a\n", ListSymbol::AlphaUpperParen, true),
|
||||
("i) a\n", ListSymbol::RomanParen, true),
|
||||
("I) a\n", ListSymbol::RomanUpperParen, true),
|
||||
];
|
||||
for (src, sym, ordered) in cases {
|
||||
let doc = parse(src);
|
||||
let BlockNode::List(list) = &doc.children[0] else {
|
||||
panic!("expected list for {src:?}");
|
||||
};
|
||||
assert_eq!(list.symbol, *sym);
|
||||
assert_eq!(list.ordered, *ordered);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_list_via_indent() {
|
||||
let doc = parse("- top\n - nested\n");
|
||||
let BlockNode::List(list) = &doc.children[0] else {
|
||||
panic!("expected list");
|
||||
};
|
||||
let item = &list.items[0];
|
||||
let sublist = item.sublist.as_ref().expect("sublist on nested item");
|
||||
assert_eq!(sublist.items.len(), 1);
|
||||
assert!(matches!(&sublist.items[0].children[0], InlineNode::Text(t) if t.content == "nested"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_item_with_checkbox() {
|
||||
let doc = parse("- [X] done\n");
|
||||
let BlockNode::List(list) = &doc.children[0] else {
|
||||
panic!("expected list");
|
||||
};
|
||||
let item = &list.items[0];
|
||||
assert_eq!(item.checkbox, Some(CheckboxState::Done));
|
||||
assert!(matches!(&item.children[0], InlineNode::Text(t) if t.content == "done"));
|
||||
}
|
||||
|
||||
// ===== Blockquotes =====
|
||||
|
||||
#[test]
|
||||
fn angle_blockquote_two_lines() {
|
||||
let doc = parse("> first\n> second\n");
|
||||
let BlockNode::Blockquote(bq) = &doc.children[0] else {
|
||||
panic!("expected blockquote");
|
||||
};
|
||||
assert_eq!(bq.children.len(), 2);
|
||||
for (i, expected) in ["first", "second"].iter().enumerate() {
|
||||
let BlockNode::Paragraph(p) = &bq.children[i] else {
|
||||
panic!("blockquote child should be a paragraph");
|
||||
};
|
||||
assert!(matches!(&p.children[0], InlineNode::Text(t) if t.content == *expected));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indent_blockquote() {
|
||||
let doc = parse(" quoted\n");
|
||||
let BlockNode::Blockquote(bq) = &doc.children[0] else {
|
||||
panic!("expected blockquote");
|
||||
};
|
||||
assert_eq!(bq.children.len(), 1);
|
||||
}
|
||||
|
||||
// ===== Tables =====
|
||||
|
||||
#[test]
|
||||
fn simple_table_row() {
|
||||
let doc = parse("| a | b |\n");
|
||||
let BlockNode::Table(t) = &doc.children[0] else {
|
||||
panic!("expected table");
|
||||
};
|
||||
assert_eq!(t.rows.len(), 1);
|
||||
let row = &t.rows[0];
|
||||
assert_eq!(row.cells.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_with_header_separator() {
|
||||
let doc = parse("| a | b |\n|---|---|\n| 1 | 2 |\n");
|
||||
let BlockNode::Table(t) = &doc.children[0] else {
|
||||
panic!("expected table");
|
||||
};
|
||||
assert!(t.has_header);
|
||||
assert!(t.rows[0].is_header);
|
||||
assert!(!t.rows[1].is_header);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_col_and_row_span_cells() {
|
||||
let doc = parse("| a | > | \\/ |\n");
|
||||
let BlockNode::Table(t) = &doc.children[0] else {
|
||||
panic!("expected table");
|
||||
};
|
||||
let cells = &t.rows[0].cells;
|
||||
assert!(!cells[0].col_span && !cells[0].row_span);
|
||||
assert!(cells[1].col_span);
|
||||
assert!(cells[2].row_span);
|
||||
}
|
||||
|
||||
// ===== Definition list =====
|
||||
|
||||
#[test]
|
||||
fn definition_term_with_inline_definition() {
|
||||
let doc = parse("Term:: Definition\n");
|
||||
let BlockNode::DefinitionList(dl) = &doc.children[0] else {
|
||||
panic!("expected definition list");
|
||||
};
|
||||
assert_eq!(dl.items.len(), 1);
|
||||
let item = &dl.items[0];
|
||||
let term = item.term.as_ref().expect("term");
|
||||
assert!(matches!(&term[0], InlineNode::Text(t) if t.content == "Term"));
|
||||
assert_eq!(item.definitions.len(), 1);
|
||||
let def = &item.definitions[0];
|
||||
assert!(matches!(&def[0], InlineNode::Text(t) if t.content == "Definition"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_definition_items() {
|
||||
let doc = parse("A:: 1\nB:: 2\n");
|
||||
let BlockNode::DefinitionList(dl) = &doc.children[0] else {
|
||||
panic!("expected definition list");
|
||||
};
|
||||
assert_eq!(dl.items.len(), 2);
|
||||
}
|
||||
|
||||
// ===== Preformatted / Math =====
|
||||
|
||||
#[test]
|
||||
fn preformatted_block_captures_content_and_language() {
|
||||
let doc = parse("{{{rust\nfn main() {}\n}}}\n");
|
||||
let BlockNode::Preformatted(p) = &doc.children[0] else {
|
||||
panic!("expected preformatted");
|
||||
};
|
||||
assert_eq!(p.content, "fn main() {}");
|
||||
assert_eq!(p.language.as_deref(), Some("rust"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn math_block_captures_content_and_environment() {
|
||||
let doc = parse("{{$%align%\nx = 1\n}}$\n");
|
||||
let BlockNode::MathBlock(m) = &doc.children[0] else {
|
||||
panic!("expected math block");
|
||||
};
|
||||
assert_eq!(m.content, "x = 1");
|
||||
assert_eq!(m.environment.as_deref(), Some("align"));
|
||||
}
|
||||
|
||||
// ===== Comments =====
|
||||
|
||||
#[test]
|
||||
fn single_line_comment_block() {
|
||||
let doc = parse("%% hidden\n");
|
||||
let BlockNode::Comment(c) = &doc.children[0] else {
|
||||
panic!("expected comment");
|
||||
};
|
||||
assert_eq!(c.content, " hidden");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_line_comment_block() {
|
||||
let doc = parse("%%+ a\nb\n+%%\n");
|
||||
let BlockNode::Comment(c) = &doc.children[0] else {
|
||||
panic!("expected comment");
|
||||
};
|
||||
assert_eq!(c.content, " a\nb");
|
||||
}
|
||||
|
||||
// ===== Inline =====
|
||||
|
||||
#[test]
|
||||
fn inline_bold_italic_strike_super_sub_code_math() {
|
||||
let doc = parse("*b* _i_ ~~s~~ ^sup^ ,,sub,, `c` $m$\n");
|
||||
let BlockNode::Paragraph(p) = &doc.children[0] else {
|
||||
panic!("expected paragraph");
|
||||
};
|
||||
let kinds: Vec<&str> = p
|
||||
.children
|
||||
.iter()
|
||||
.map(|n| match n {
|
||||
InlineNode::Bold(_) => "Bold",
|
||||
InlineNode::Italic(_) => "Italic",
|
||||
InlineNode::Strikethrough(_) => "Strike",
|
||||
InlineNode::Superscript(_) => "Sup",
|
||||
InlineNode::Subscript(_) => "Sub",
|
||||
InlineNode::Code(_) => "Code",
|
||||
InlineNode::MathInline(_) => "Math",
|
||||
InlineNode::Text(_) => "Text",
|
||||
_ => "Other",
|
||||
})
|
||||
.collect();
|
||||
assert!(kinds.contains(&"Bold"));
|
||||
assert!(kinds.contains(&"Italic"));
|
||||
assert!(kinds.contains(&"Strike"));
|
||||
assert!(kinds.contains(&"Sup"));
|
||||
assert!(kinds.contains(&"Sub"));
|
||||
assert!(kinds.contains(&"Code"));
|
||||
assert!(kinds.contains(&"Math"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unmatched_bold_falls_back_to_text() {
|
||||
let doc = parse("2 * 3 = 6\n");
|
||||
let BlockNode::Paragraph(p) = &doc.children[0] else {
|
||||
panic!("expected paragraph");
|
||||
};
|
||||
// No Bold node should appear; the orphan `*` becomes literal text.
|
||||
assert!(!p.children.iter().any(|n| matches!(n, InlineNode::Bold(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_italic_around_bold() {
|
||||
let doc = parse("_*x*_\n");
|
||||
let BlockNode::Paragraph(p) = &doc.children[0] else {
|
||||
panic!("expected paragraph");
|
||||
};
|
||||
let InlineNode::Italic(it) = &p.children[0] else {
|
||||
panic!("expected italic outer");
|
||||
};
|
||||
let InlineNode::Bold(b) = &it.children[0] else {
|
||||
panic!("expected bold inside italic");
|
||||
};
|
||||
assert!(matches!(&b.children[0], InlineNode::Text(t) if t.content == "x"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keywords_become_keyword_nodes() {
|
||||
let cases = [
|
||||
("TODO", Keyword::Todo),
|
||||
("DONE", Keyword::Done),
|
||||
("STARTED", Keyword::Started),
|
||||
("FIXME", Keyword::Fixme),
|
||||
("FIXED", Keyword::Fixed),
|
||||
("XXX", Keyword::Xxx),
|
||||
("STOPPED", Keyword::Stopped),
|
||||
];
|
||||
for (src, expected) in cases {
|
||||
let doc = parse(&format!("{src} stuff\n"));
|
||||
let BlockNode::Paragraph(p) = &doc.children[0] else {
|
||||
panic!("expected paragraph");
|
||||
};
|
||||
let InlineNode::Keyword(k) = &p.children[0] else {
|
||||
panic!("expected keyword for {src}");
|
||||
};
|
||||
assert_eq!(k.keyword, expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_url_inline() {
|
||||
let doc = parse("see https://example.com here\n");
|
||||
let BlockNode::Paragraph(p) = &doc.children[0] else {
|
||||
panic!("expected paragraph");
|
||||
};
|
||||
assert!(p
|
||||
.children
|
||||
.iter()
|
||||
.any(|n| matches!(n, InlineNode::RawUrl(u) if u.url == "https://example.com")));
|
||||
}
|
||||
|
||||
// ===== Wikilinks / external links / transclusions =====
|
||||
|
||||
#[test]
|
||||
fn bare_wikilink() {
|
||||
let doc = parse("[[Page]]\n");
|
||||
let BlockNode::Paragraph(p) = &doc.children[0] else {
|
||||
panic!("expected paragraph");
|
||||
};
|
||||
let InlineNode::WikiLink(w) = &p.children[0] else {
|
||||
panic!("expected wikilink");
|
||||
};
|
||||
assert_eq!(w.target.kind, LinkKind::Wiki);
|
||||
assert_eq!(w.target.path.as_deref(), Some("Page"));
|
||||
assert!(w.description.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wikilink_with_anchor() {
|
||||
let doc = parse("[[Page#Section]]\n");
|
||||
let BlockNode::Paragraph(p) = &doc.children[0] else {
|
||||
panic!("expected paragraph");
|
||||
};
|
||||
let InlineNode::WikiLink(w) = &p.children[0] else {
|
||||
panic!("expected wikilink");
|
||||
};
|
||||
assert_eq!(w.target.path.as_deref(), Some("Page"));
|
||||
assert_eq!(w.target.anchor.as_deref(), Some("Section"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anchor_only_wikilink() {
|
||||
let doc = parse("[[#Section]]\n");
|
||||
let InlineNode::WikiLink(w) = &if let BlockNode::Paragraph(p) = &doc.children[0] {
|
||||
p
|
||||
} else {
|
||||
panic!()
|
||||
}
|
||||
.children[0] else {
|
||||
panic!("expected wikilink");
|
||||
};
|
||||
assert_eq!(w.target.kind, LinkKind::AnchorOnly);
|
||||
assert_eq!(w.target.anchor.as_deref(), Some("Section"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_relative_and_filesystem_absolute_wikilinks() {
|
||||
let doc = parse("[[/Page]] [[//etc/hosts]]\n");
|
||||
let BlockNode::Paragraph(p) = &doc.children[0] else {
|
||||
panic!("expected paragraph");
|
||||
};
|
||||
let mut links = p.children.iter().filter_map(|n| match n {
|
||||
InlineNode::WikiLink(w) => Some(w),
|
||||
_ => None,
|
||||
});
|
||||
let root = links.next().expect("root-relative");
|
||||
assert_eq!(root.target.kind, LinkKind::Wiki);
|
||||
assert!(root.target.is_absolute);
|
||||
assert_eq!(root.target.path.as_deref(), Some("Page"));
|
||||
|
||||
let local = links.next().expect("filesystem-absolute");
|
||||
assert_eq!(local.target.kind, LinkKind::Local);
|
||||
assert!(local.target.is_absolute);
|
||||
assert_eq!(local.target.path.as_deref(), Some("etc/hosts"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn directory_wikilink() {
|
||||
let doc = parse("[[dir/]]\n");
|
||||
let BlockNode::Paragraph(p) = &doc.children[0] else {
|
||||
panic!("expected paragraph");
|
||||
};
|
||||
let InlineNode::WikiLink(w) = &p.children[0] else {
|
||||
panic!("expected wikilink");
|
||||
};
|
||||
assert!(w.target.is_directory);
|
||||
assert_eq!(w.target.path.as_deref(), Some("dir"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interwiki_links_numbered_and_named() {
|
||||
let doc = parse("[[wiki1:Page]] [[wn.Notes:Page]]\n");
|
||||
let BlockNode::Paragraph(p) = &doc.children[0] else {
|
||||
panic!("expected paragraph");
|
||||
};
|
||||
let mut links = p.children.iter().filter_map(|n| match n {
|
||||
InlineNode::WikiLink(w) => Some(w),
|
||||
_ => None,
|
||||
});
|
||||
let numbered = links.next().expect("numbered");
|
||||
assert_eq!(numbered.target.kind, LinkKind::Interwiki);
|
||||
assert_eq!(numbered.target.wiki_index, Some(1));
|
||||
assert_eq!(numbered.target.path.as_deref(), Some("Page"));
|
||||
|
||||
let named = links.next().expect("named");
|
||||
assert_eq!(named.target.kind, LinkKind::Interwiki);
|
||||
assert_eq!(named.target.wiki_name.as_deref(), Some("Notes"));
|
||||
assert_eq!(named.target.path.as_deref(), Some("Page"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diary_and_file_and_local_kinds() {
|
||||
for (src, expected) in [
|
||||
("[[diary:2026-05-10]]", LinkKind::Diary),
|
||||
("[[file:/tmp/note.txt]]", LinkKind::File),
|
||||
("[[local:rel/path]]", LinkKind::Local),
|
||||
] {
|
||||
let doc = parse(&format!("{src}\n"));
|
||||
let BlockNode::Paragraph(p) = &doc.children[0] else {
|
||||
panic!("expected paragraph");
|
||||
};
|
||||
let InlineNode::WikiLink(w) = &p.children[0] else {
|
||||
panic!("expected wikilink for {src}");
|
||||
};
|
||||
assert_eq!(w.target.kind, expected, "src {src}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_inside_brackets_is_external_link() {
|
||||
let doc = parse("[[https://example.com|docs]]\n");
|
||||
let BlockNode::Paragraph(p) = &doc.children[0] else {
|
||||
panic!("expected paragraph");
|
||||
};
|
||||
let InlineNode::ExternalLink(e) = &p.children[0] else {
|
||||
panic!("expected external link");
|
||||
};
|
||||
assert_eq!(e.url, "https://example.com");
|
||||
let desc = e.description.as_ref().expect("description");
|
||||
assert!(matches!(&desc[0], InlineNode::Text(t) if t.content == "docs"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transclusion_with_alt() {
|
||||
let doc = parse("{{img.png|alt text}}\n");
|
||||
let BlockNode::Paragraph(p) = &doc.children[0] else {
|
||||
panic!("expected paragraph");
|
||||
};
|
||||
let InlineNode::Transclusion(t) = &p.children[0] else {
|
||||
panic!("expected transclusion");
|
||||
};
|
||||
assert_eq!(t.url, "img.png");
|
||||
assert_eq!(t.alt.as_deref(), Some("alt text"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transclusion_with_alt_and_attrs() {
|
||||
let doc = parse("{{img.png|alt|class=hi|width=200}}\n");
|
||||
let BlockNode::Paragraph(p) = &doc.children[0] else {
|
||||
panic!("expected paragraph");
|
||||
};
|
||||
let InlineNode::Transclusion(t) = &p.children[0] else {
|
||||
panic!("expected transclusion");
|
||||
};
|
||||
assert_eq!(t.url, "img.png");
|
||||
assert_eq!(t.alt.as_deref(), Some("alt"));
|
||||
assert_eq!(t.attrs.get("class").map(String::as_str), Some("hi"));
|
||||
assert_eq!(t.attrs.get("width").map(String::as_str), Some("200"));
|
||||
}
|
||||
|
||||
// ===== Resilience =====
|
||||
|
||||
#[test]
|
||||
fn unterminated_wikilink_falls_back_to_text() {
|
||||
let doc = parse("[[Unterminated\n");
|
||||
let BlockNode::Paragraph(p) = &doc.children[0] else {
|
||||
panic!("expected paragraph");
|
||||
};
|
||||
// First child should be literal "[[" text.
|
||||
assert!(matches!(&p.children[0], InlineNode::Text(t) if t.content == "[["));
|
||||
}
|
||||
|
||||
// ===== End-to-end through the registry =====
|
||||
|
||||
#[test]
|
||||
fn vimwiki_syntax_registers_and_dispatches() {
|
||||
use nuwiki_core::syntax::SyntaxRegistry;
|
||||
|
||||
let mut reg = SyntaxRegistry::new();
|
||||
reg.register(VimwikiSyntax::new());
|
||||
|
||||
let plugin = reg.detect_from_extension(".wiki").expect("plugin by ext");
|
||||
assert_eq!(plugin.id(), "vimwiki");
|
||||
|
||||
let doc = plugin.parse("= Hello =\n");
|
||||
assert!(matches!(doc.children[0], BlockNode::Heading(_)));
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
//! Integration tests for the syntax plugin interface.
|
||||
|
||||
use nuwiki_core::ast::{
|
||||
inline::InlineNode, BlockNode, DocumentNode, ParagraphNode, Span, TextNode,
|
||||
};
|
||||
use nuwiki_core::syntax::{Lexer, Parser, SyntaxPlugin, SyntaxRegistry, TokenStream};
|
||||
|
||||
// --- A minimal mock plugin: tokenises the input on whitespace, then wraps
|
||||
// the joined text in a single Paragraph. Just enough surface to exercise
|
||||
// every trait + the registry.
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct MockToken(String);
|
||||
|
||||
struct MockLexer;
|
||||
impl Lexer for MockLexer {
|
||||
type Token = MockToken;
|
||||
|
||||
fn lex(&self, text: &str) -> TokenStream<MockToken> {
|
||||
TokenStream::from_vec(
|
||||
text.split_whitespace()
|
||||
.map(|w| MockToken(w.to_owned()))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct MockParser;
|
||||
impl Parser for MockParser {
|
||||
type Token = MockToken;
|
||||
|
||||
fn parse(&self, tokens: TokenStream<MockToken>) -> DocumentNode {
|
||||
let joined = tokens
|
||||
.iter()
|
||||
.map(|t| t.0.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let paragraph = BlockNode::Paragraph(ParagraphNode {
|
||||
span: Span::default(),
|
||||
children: vec![InlineNode::Text(TextNode {
|
||||
span: Span::default(),
|
||||
content: joined,
|
||||
})],
|
||||
});
|
||||
DocumentNode {
|
||||
span: Span::default(),
|
||||
children: vec![paragraph],
|
||||
metadata: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct MockPlugin;
|
||||
impl SyntaxPlugin for MockPlugin {
|
||||
fn id(&self) -> &str {
|
||||
"mock"
|
||||
}
|
||||
fn display_name(&self) -> &str {
|
||||
"Mock Syntax"
|
||||
}
|
||||
fn file_extensions(&self) -> &[&str] {
|
||||
&[".mock", ".mk"]
|
||||
}
|
||||
fn parse(&self, text: &str) -> DocumentNode {
|
||||
MockParser.parse(MockLexer.lex(text))
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_stream_roundtrip() {
|
||||
let mut s = TokenStream::<u32>::new();
|
||||
assert!(s.is_empty());
|
||||
s.push(1);
|
||||
s.push(2);
|
||||
s.push(3);
|
||||
assert_eq!(s.len(), 3);
|
||||
assert_eq!(s.as_slice(), &[1, 2, 3]);
|
||||
assert_eq!(s.iter().sum::<u32>(), 6);
|
||||
assert_eq!((&s).into_iter().sum::<u32>(), 6);
|
||||
assert_eq!(s.into_vec(), vec![1, 2, 3]);
|
||||
|
||||
let from_vec: TokenStream<&str> = vec!["a", "b"].into();
|
||||
assert_eq!(from_vec.len(), 2);
|
||||
let collected: Vec<&str> = from_vec.into_iter().collect();
|
||||
assert_eq!(collected, vec!["a", "b"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lexer_and_parser_chain_through_plugin() {
|
||||
let plugin = MockPlugin;
|
||||
let doc = plugin.parse(" hello world ");
|
||||
|
||||
let BlockNode::Paragraph(p) = &doc.children[0] else {
|
||||
panic!("expected a paragraph, got {:?}", doc.children[0]);
|
||||
};
|
||||
let InlineNode::Text(t) = &p.children[0] else {
|
||||
panic!("expected a text node");
|
||||
};
|
||||
assert_eq!(t.content, "hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_lookup_by_id_and_extension() {
|
||||
let mut registry = SyntaxRegistry::new();
|
||||
assert!(registry.is_empty());
|
||||
|
||||
registry.register(MockPlugin);
|
||||
assert_eq!(registry.len(), 1);
|
||||
|
||||
let by_id = registry
|
||||
.get("mock")
|
||||
.expect("plugin should be findable by id");
|
||||
assert_eq!(by_id.display_name(), "Mock Syntax");
|
||||
|
||||
let by_ext = registry
|
||||
.detect_from_extension(".mk")
|
||||
.expect("plugin should be findable by secondary extension");
|
||||
assert_eq!(by_ext.id(), "mock");
|
||||
|
||||
assert!(registry.get("nonexistent").is_none());
|
||||
assert!(registry.detect_from_extension(".unknown").is_none());
|
||||
|
||||
// Iteration order matches registration order.
|
||||
let ids: Vec<&str> = registry.iter().map(|p| p.id()).collect();
|
||||
assert_eq!(ids, vec!["mock"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_can_parse_through_trait_object() {
|
||||
let mut registry = SyntaxRegistry::new();
|
||||
registry.register(MockPlugin);
|
||||
|
||||
let plugin = registry.detect_from_extension(".mock").unwrap();
|
||||
let doc = plugin.parse("a b c");
|
||||
assert_eq!(doc.children.len(), 1);
|
||||
}
|
||||
|
||||
/// Compile-time check: SyntaxRegistry must be Send + Sync so the LSP server
|
||||
/// can share it across handler tasks.
|
||||
#[test]
|
||||
fn registry_is_send_and_sync() {
|
||||
fn assert_send_sync<T: Send + Sync>() {}
|
||||
assert_send_sync::<SyntaxRegistry>();
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
//! Cluster 7 — Markdown-style alignment markers on the header
|
||||
//! separator row (`|:--|--:|:--:|`) propagate to the AST and HTML.
|
||||
|
||||
use nuwiki_core::ast::{BlockNode, TableAlign};
|
||||
use nuwiki_core::render::{HtmlRenderer, Renderer};
|
||||
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
|
||||
use nuwiki_core::syntax::SyntaxPlugin;
|
||||
|
||||
fn parse(src: &str) -> nuwiki_core::ast::DocumentNode {
|
||||
VimwikiSyntax::new().parse(src)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn separator_row_with_no_anchors_yields_defaults() {
|
||||
let doc = parse("| h1 | h2 |\n|----|----|\n| a | b |\n");
|
||||
let table = match &doc.children[0] {
|
||||
BlockNode::Table(t) => t,
|
||||
_ => panic!("expected table"),
|
||||
};
|
||||
assert_eq!(
|
||||
table.alignments,
|
||||
vec![TableAlign::Default, TableAlign::Default]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn left_right_center_anchors_parsed() {
|
||||
let doc = parse("| a | b | c |\n|:---|---:|:---:|\n| 1 | 2 | 3 |\n");
|
||||
let table = match &doc.children[0] {
|
||||
BlockNode::Table(t) => t,
|
||||
_ => panic!("expected table"),
|
||||
};
|
||||
assert_eq!(
|
||||
table.alignments,
|
||||
vec![TableAlign::Left, TableAlign::Right, TableAlign::Center]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn html_renderer_emits_text_align_style() {
|
||||
let doc = parse("| a | b | c |\n|:---|---:|:---:|\n| 1 | 2 | 3 |\n");
|
||||
let out = HtmlRenderer::new().render_to_string(&doc).unwrap();
|
||||
assert!(
|
||||
out.contains(r#"<th style="text-align: left;"> a </th>"#),
|
||||
"got: {out}"
|
||||
);
|
||||
assert!(out.contains(r#"<th style="text-align: right;"> b </th>"#));
|
||||
assert!(out.contains(r#"<th style="text-align: center;"> c </th>"#));
|
||||
assert!(out.contains(r#"<td style="text-align: left;"> 1 </td>"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_without_alignment_omits_style_attr() {
|
||||
let doc = parse("| a | b |\n|---|---|\n| 1 | 2 |\n");
|
||||
let out = HtmlRenderer::new().render_to_string(&doc).unwrap();
|
||||
assert!(!out.contains("text-align"));
|
||||
}
|
||||
@@ -1,204 +0,0 @@
|
||||
//! Tag tests: lexer + parser + renderer behaviour on
|
||||
//! `:tag:` lines, plus the scope-detection rules.
|
||||
|
||||
use nuwiki_core::ast::{BlockNode, TagScope};
|
||||
use nuwiki_core::render::{HtmlRenderer, Renderer};
|
||||
use nuwiki_core::syntax::vimwiki::{VimwikiLexer, VimwikiSyntax, VimwikiTokenKind};
|
||||
use nuwiki_core::syntax::Lexer;
|
||||
use nuwiki_core::syntax::SyntaxPlugin;
|
||||
|
||||
fn lex(src: &str) -> Vec<VimwikiTokenKind> {
|
||||
VimwikiLexer::new()
|
||||
.lex(src)
|
||||
.into_iter()
|
||||
.map(|t| t.kind)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn parse(src: &str) -> nuwiki_core::ast::DocumentNode {
|
||||
VimwikiSyntax::new().parse(src)
|
||||
}
|
||||
|
||||
fn render(src: &str) -> String {
|
||||
let doc = parse(src);
|
||||
HtmlRenderer::new().render_to_string(&doc).unwrap()
|
||||
}
|
||||
|
||||
// ===== Lexer =====
|
||||
|
||||
#[test]
|
||||
fn lexer_emits_tag_for_single_tag_line() {
|
||||
let toks = lex(":todo:\n");
|
||||
assert!(toks
|
||||
.iter()
|
||||
.any(|t| matches!(t, VimwikiTokenKind::Tag(v) if v == &vec!["todo".to_string()])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lexer_emits_tag_for_multiple_chained_tags() {
|
||||
let toks = lex(":one:two:three:\n");
|
||||
let names = toks
|
||||
.iter()
|
||||
.find_map(|t| match t {
|
||||
VimwikiTokenKind::Tag(v) => Some(v.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.expect("tag token");
|
||||
assert_eq!(names, vec!["one", "two", "three"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lexer_rejects_empty_segment() {
|
||||
// `::tag::` — empty leading segment between the doubled colons.
|
||||
let toks = lex("::tag::\n");
|
||||
assert!(!toks.iter().any(|t| matches!(t, VimwikiTokenKind::Tag(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lexer_rejects_tag_with_whitespace() {
|
||||
let toks = lex(":one tag:other:\n");
|
||||
assert!(!toks.iter().any(|t| matches!(t, VimwikiTokenKind::Tag(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lexer_does_not_collide_with_definition_term() {
|
||||
// `Term::` is a definition-term marker (text *before* the `::`); a
|
||||
// tag line has nothing before its leading `:`. The two patterns must
|
||||
// not steal each other's lines.
|
||||
let toks = lex("Term:: Definition\n:foo:\n");
|
||||
assert!(toks
|
||||
.iter()
|
||||
.any(|t| matches!(t, VimwikiTokenKind::DefinitionTermMarker)));
|
||||
assert!(toks.iter().any(|t| matches!(t, VimwikiTokenKind::Tag(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lexer_accepts_indented_tag_line() {
|
||||
let toks = lex(" :indented:\n");
|
||||
assert!(toks.iter().any(|t| matches!(t, VimwikiTokenKind::Tag(_))));
|
||||
}
|
||||
|
||||
// ===== Parser scope rules =====
|
||||
|
||||
#[test]
|
||||
fn scope_is_file_when_tag_is_on_line_0_or_1() {
|
||||
let doc = parse(":todo:other:\n= Heading =\n");
|
||||
let BlockNode::Tag(t) = &doc.children[0] else {
|
||||
panic!(
|
||||
"expected first block to be a Tag, got {:?}",
|
||||
doc.children[0]
|
||||
);
|
||||
};
|
||||
assert_eq!(t.scope, TagScope::File);
|
||||
assert_eq!(t.tags, vec!["todo", "other"]);
|
||||
assert_eq!(doc.metadata.tags, vec!["todo", "other"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scope_is_file_for_tag_on_line_1() {
|
||||
let doc = parse("a paragraph\n:todo:\n");
|
||||
// Tag is on line 1, so still file-level.
|
||||
let tag = doc
|
||||
.children
|
||||
.iter()
|
||||
.find_map(|b| match b {
|
||||
BlockNode::Tag(t) => Some(t),
|
||||
_ => None,
|
||||
})
|
||||
.expect("tag block");
|
||||
assert_eq!(tag.scope, TagScope::File);
|
||||
assert!(doc.metadata.tags.contains(&"todo".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scope_is_heading_when_tag_is_one_or_two_lines_below_heading() {
|
||||
// Heading at line 0; tag at line 1 (one below) → Heading(0).
|
||||
let doc = parse("= H1 =\n:hot:\n");
|
||||
let tag = doc
|
||||
.children
|
||||
.iter()
|
||||
.find_map(|b| match b {
|
||||
BlockNode::Tag(t) => Some(t),
|
||||
_ => None,
|
||||
})
|
||||
.expect("tag block");
|
||||
// Tag at line 1 is *also* line ≤ 1 → file rule wins (matches vimwiki:
|
||||
// file rule applies to lines 0-1 regardless of preceding heading).
|
||||
assert_eq!(tag.scope, TagScope::File);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scope_is_heading_for_tag_two_lines_below_a_deeper_heading() {
|
||||
// Heading at line 3; tag at line 4 (one below).
|
||||
let src = "first\n\n\n= Section =\n:scoped:\n";
|
||||
let doc = parse(src);
|
||||
let tag = doc
|
||||
.children
|
||||
.iter()
|
||||
.find_map(|b| match b {
|
||||
BlockNode::Tag(t) => Some(t),
|
||||
_ => None,
|
||||
})
|
||||
.expect("tag block");
|
||||
// Heading is the only one seen; index 0.
|
||||
assert_eq!(tag.scope, TagScope::Heading(0));
|
||||
// Heading-scope tags do NOT contribute to file-level metadata.
|
||||
assert!(doc.metadata.tags.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scope_is_standalone_for_distant_tag() {
|
||||
// Heading at line 3; tag at line 6 → 3 lines below → out of the
|
||||
// heading window (>2), so standalone.
|
||||
let src = "x\n\n\n= H =\n\n\n:wandering:\n";
|
||||
let doc = parse(src);
|
||||
let tag = doc
|
||||
.children
|
||||
.iter()
|
||||
.find_map(|b| match b {
|
||||
BlockNode::Tag(t) => Some(t),
|
||||
_ => None,
|
||||
})
|
||||
.expect("tag block");
|
||||
assert_eq!(tag.scope, TagScope::Standalone);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scope_is_standalone_when_no_heading_precedes() {
|
||||
// No heading at all and not on lines 0-1.
|
||||
let doc = parse("a\nb\nc\n:lonely:\n");
|
||||
let tag = doc
|
||||
.children
|
||||
.iter()
|
||||
.find_map(|b| match b {
|
||||
BlockNode::Tag(t) => Some(t),
|
||||
_ => None,
|
||||
})
|
||||
.expect("tag block");
|
||||
assert_eq!(tag.scope, TagScope::Standalone);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_tags_are_deduped_in_metadata() {
|
||||
let doc = parse(":a:b:\n:b:c:\n");
|
||||
assert_eq!(doc.metadata.tags, vec!["a", "b", "c"]);
|
||||
}
|
||||
|
||||
// ===== Renderer =====
|
||||
|
||||
#[test]
|
||||
fn renderer_emits_div_with_tag_spans() {
|
||||
let html = render(":todo:done:\n");
|
||||
assert!(html.contains("<div class=\"tags\">"));
|
||||
assert!(html.contains("<span class=\"tag\" id=\"tag-todo\">todo</span>"));
|
||||
assert!(html.contains("<span class=\"tag\" id=\"tag-done\">done</span>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renderer_escapes_html_inside_tag_names() {
|
||||
// Tags shouldn't contain `<`/`>` per the lexer rule (whitespace
|
||||
// forbidden, but `<` isn't), so this checks the renderer's escaping
|
||||
// fires anyway as defence-in-depth.
|
||||
let html = render(":a<b:\n");
|
||||
assert!(html.contains("a<b"));
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
//! Cluster 8 — transclusion attribute parsing. The basic
|
||||
//! `{{url|alt|key=val}}` shape was already wired; this test pins
|
||||
//! down quote-stripping on quoted values (`key="quoted value"`)
|
||||
//! so an HTML renderer doesn't double-emit `"` artefacts.
|
||||
|
||||
use nuwiki_core::ast::{BlockNode, InlineNode};
|
||||
use nuwiki_core::render::{HtmlRenderer, Renderer};
|
||||
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
|
||||
use nuwiki_core::syntax::SyntaxPlugin;
|
||||
|
||||
fn parse(src: &str) -> nuwiki_core::ast::DocumentNode {
|
||||
VimwikiSyntax::new().parse(src)
|
||||
}
|
||||
|
||||
fn first_transclusion(doc: &nuwiki_core::ast::DocumentNode) -> &nuwiki_core::ast::TransclusionNode {
|
||||
let para = match &doc.children[0] {
|
||||
BlockNode::Paragraph(p) => p,
|
||||
_ => panic!("expected paragraph"),
|
||||
};
|
||||
for child in ¶.children {
|
||||
if let InlineNode::Transclusion(t) = child {
|
||||
return t;
|
||||
}
|
||||
}
|
||||
panic!("no transclusion in paragraph");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transclusion_attrs_strip_double_quotes() {
|
||||
let doc = parse(r#"{{cat.png|cat|style="border: 1px"}}"#);
|
||||
let t = first_transclusion(&doc);
|
||||
assert_eq!(t.url, "cat.png");
|
||||
assert_eq!(t.alt.as_deref(), Some("cat"));
|
||||
assert_eq!(
|
||||
t.attrs.get("style").map(String::as_str),
|
||||
Some("border: 1px")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transclusion_attrs_strip_single_quotes() {
|
||||
let doc = parse("{{cat.png|cat|class='thumb'}}");
|
||||
let t = first_transclusion(&doc);
|
||||
assert_eq!(t.attrs.get("class").map(String::as_str), Some("thumb"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transclusion_attrs_leave_unquoted_values_alone() {
|
||||
let doc = parse("{{cat.png|cat|width=200}}");
|
||||
let t = first_transclusion(&doc);
|
||||
assert_eq!(t.attrs.get("width").map(String::as_str), Some("200"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transclusion_renders_clean_html_with_quoted_attr() {
|
||||
let doc = parse(r#"{{cat.png|cat|style="border: 1px"}}"#);
|
||||
let out = HtmlRenderer::new().render_to_string(&doc).unwrap();
|
||||
assert!(out.contains(r#"style="border: 1px""#), "got: {out}");
|
||||
assert!(!out.contains("""));
|
||||
}
|
||||
@@ -1,215 +0,0 @@
|
||||
//! Smoke test for the AST visitor: build a small document by hand and
|
||||
//! confirm a Visitor descends into every nested node exactly once.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use nuwiki_core::ast::{
|
||||
BlockNode, BoldNode, DocumentNode, ExternalLinkNode, HeadingNode, InlineNode, ListItemNode,
|
||||
ListNode, ListSymbol, ParagraphNode, Span, TableCellNode, TableNode, TableRowNode, TextNode,
|
||||
TransclusionNode, Visitor,
|
||||
};
|
||||
|
||||
#[derive(Default)]
|
||||
struct Counter {
|
||||
headings: usize,
|
||||
paragraphs: usize,
|
||||
text: usize,
|
||||
bold: usize,
|
||||
list_items: usize,
|
||||
table_cells: usize,
|
||||
external_links: usize,
|
||||
transclusions: usize,
|
||||
blocks: usize,
|
||||
inlines: usize,
|
||||
}
|
||||
|
||||
impl Visitor for Counter {
|
||||
fn visit_block(&mut self, node: &BlockNode) {
|
||||
self.blocks += 1;
|
||||
nuwiki_core::ast::walk_block(self, node);
|
||||
}
|
||||
fn visit_inline(&mut self, node: &InlineNode) {
|
||||
self.inlines += 1;
|
||||
nuwiki_core::ast::walk_inline(self, node);
|
||||
}
|
||||
fn visit_heading(&mut self, node: &HeadingNode) {
|
||||
self.headings += 1;
|
||||
for child in &node.children {
|
||||
self.visit_inline(child);
|
||||
}
|
||||
}
|
||||
fn visit_paragraph(&mut self, node: &ParagraphNode) {
|
||||
self.paragraphs += 1;
|
||||
for child in &node.children {
|
||||
self.visit_inline(child);
|
||||
}
|
||||
}
|
||||
fn visit_text(&mut self, _: &TextNode) {
|
||||
self.text += 1;
|
||||
}
|
||||
fn visit_bold(&mut self, node: &BoldNode) {
|
||||
self.bold += 1;
|
||||
for child in &node.children {
|
||||
self.visit_inline(child);
|
||||
}
|
||||
}
|
||||
fn visit_list_item(&mut self, node: &ListItemNode) {
|
||||
self.list_items += 1;
|
||||
nuwiki_core::ast::walk_list_item(self, node);
|
||||
}
|
||||
fn visit_table_cell(&mut self, node: &TableCellNode) {
|
||||
self.table_cells += 1;
|
||||
for child in &node.children {
|
||||
self.visit_inline(child);
|
||||
}
|
||||
}
|
||||
fn visit_external_link(&mut self, node: &ExternalLinkNode) {
|
||||
self.external_links += 1;
|
||||
if let Some(desc) = &node.description {
|
||||
for child in desc {
|
||||
self.visit_inline(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
fn visit_transclusion(&mut self, _: &TransclusionNode) {
|
||||
self.transclusions += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn text(s: &str) -> InlineNode {
|
||||
InlineNode::Text(TextNode {
|
||||
span: Span::default(),
|
||||
content: s.into(),
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visitor_descends_into_every_node() {
|
||||
// = Title with *bold* word =
|
||||
let heading = BlockNode::Heading(HeadingNode {
|
||||
span: Span::default(),
|
||||
level: 1,
|
||||
centered: false,
|
||||
children: vec![
|
||||
text("Title with "),
|
||||
InlineNode::Bold(BoldNode {
|
||||
span: Span::default(),
|
||||
children: vec![text("bold")],
|
||||
}),
|
||||
text(" word"),
|
||||
],
|
||||
});
|
||||
|
||||
// A paragraph containing an external link with two text children in its
|
||||
// description, and a transclusion sibling.
|
||||
let paragraph = BlockNode::Paragraph(ParagraphNode {
|
||||
span: Span::default(),
|
||||
children: vec![
|
||||
InlineNode::ExternalLink(ExternalLinkNode {
|
||||
span: Span::default(),
|
||||
url: "https://example.com".into(),
|
||||
description: Some(vec![text("see "), text("docs")]),
|
||||
}),
|
||||
InlineNode::Transclusion(TransclusionNode {
|
||||
span: Span::default(),
|
||||
url: "image.png".into(),
|
||||
alt: None,
|
||||
attrs: HashMap::new(),
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
// - one
|
||||
// - two
|
||||
let list = BlockNode::List(ListNode {
|
||||
span: Span::default(),
|
||||
ordered: false,
|
||||
symbol: ListSymbol::Dash,
|
||||
items: vec![
|
||||
ListItemNode {
|
||||
span: Span::default(),
|
||||
symbol: ListSymbol::Dash,
|
||||
level: 0,
|
||||
checkbox: None,
|
||||
children: vec![text("one")],
|
||||
sublist: None,
|
||||
},
|
||||
ListItemNode {
|
||||
span: Span::default(),
|
||||
symbol: ListSymbol::Dash,
|
||||
level: 0,
|
||||
checkbox: None,
|
||||
children: vec![text("two")],
|
||||
sublist: None,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// | a | b |
|
||||
let table = BlockNode::Table(TableNode {
|
||||
span: Span::default(),
|
||||
has_header: false,
|
||||
alignments: Vec::new(),
|
||||
rows: vec![TableRowNode {
|
||||
span: Span::default(),
|
||||
is_header: false,
|
||||
cells: vec![
|
||||
TableCellNode {
|
||||
span: Span::default(),
|
||||
children: vec![text("a")],
|
||||
col_span: false,
|
||||
row_span: false,
|
||||
},
|
||||
TableCellNode {
|
||||
span: Span::default(),
|
||||
children: vec![text("b")],
|
||||
col_span: false,
|
||||
row_span: false,
|
||||
},
|
||||
],
|
||||
}],
|
||||
});
|
||||
|
||||
let doc = DocumentNode {
|
||||
span: Span::default(),
|
||||
metadata: Default::default(),
|
||||
children: vec![heading, paragraph, list, table],
|
||||
};
|
||||
|
||||
let mut counter = Counter::default();
|
||||
counter.visit_document(&doc);
|
||||
|
||||
// Block-level: 4 top-level + 2 list items aren't blocks
|
||||
assert_eq!(counter.blocks, 4, "top-level blocks");
|
||||
assert_eq!(counter.headings, 1);
|
||||
assert_eq!(counter.paragraphs, 1);
|
||||
assert_eq!(counter.list_items, 2);
|
||||
// 1 cell row with 2 cells
|
||||
assert_eq!(counter.table_cells, 2);
|
||||
|
||||
// Inlines:
|
||||
// heading: 3 ("Title with ", Bold, " word") + 1 (bold child) = 4
|
||||
// paragraph: 2 (ExternalLink, Transclusion) + 2 (link desc) = 4
|
||||
// list items: 2 (one each)
|
||||
// table cells: 2 (one each)
|
||||
// total: 12
|
||||
assert_eq!(counter.inlines, 12);
|
||||
// Text leaves: heading 3, link description 2, list items 2, table cells 2 = 9
|
||||
assert_eq!(counter.text, 9, "Text leaves");
|
||||
assert_eq!(counter.bold, 1);
|
||||
assert_eq!(counter.external_links, 1);
|
||||
assert_eq!(counter.transclusions, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn document_default_is_empty() {
|
||||
let doc = DocumentNode::default();
|
||||
assert!(doc.children.is_empty());
|
||||
assert_eq!(doc.metadata.title, None);
|
||||
assert!(!doc.metadata.nohtml);
|
||||
|
||||
let mut counter = Counter::default();
|
||||
counter.visit_document(&doc);
|
||||
assert_eq!(counter.blocks, 0);
|
||||
assert_eq!(counter.inlines, 0);
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
[package]
|
||||
name = "nuwiki-ls"
|
||||
description = "nuwiki language server binary — speaks LSP over stdio."
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
homepage.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "nuwiki-ls"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
nuwiki-lsp = { path = "../nuwiki-lsp", version = "0.1.0" }
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "io-std"] }
|
||||
@@ -1,6 +0,0 @@
|
||||
//! `nuwiki-ls` — language-server binary. Speaks LSP over stdio.
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> std::io::Result<()> {
|
||||
nuwiki_lsp::run_stdio().await
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
[package]
|
||||
name = "nuwiki-lsp"
|
||||
description = "LSP protocol bridge for nuwiki, built on top of nuwiki-core."
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
homepage.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
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"
|
||||
async-trait = "0.1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,638 +0,0 @@
|
||||
//! Server-side runtime config.
|
||||
//!
|
||||
//! Populated from `InitializeParams.initialization_options` at boot and
|
||||
//! refreshed via `workspace/didChangeConfiguration`. Legacy single-wiki
|
||||
//! shapes are accepted and desugared into the `wikis = [...]` form so
|
||||
//! existing setups keep working.
|
||||
//!
|
||||
//! `WikiConfig` (diary path, html path, template options, …) and the
|
||||
//! top-level `Config` (diagnostic severity, mappings opt-in, …) carry the
|
||||
//! fields actually consumed by the server.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::Deserialize;
|
||||
use tower_lsp::lsp_types::{InitializeParams, Url};
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Config {
|
||||
pub wikis: Vec<WikiConfig>,
|
||||
pub diagnostic: DiagnosticConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WikiConfig {
|
||||
pub name: String,
|
||||
pub root: PathBuf,
|
||||
pub file_extension: String,
|
||||
pub syntax: String,
|
||||
/// Stem of the wiki's index page (vimwiki's `index`, default
|
||||
/// `"index"`). `<root>/<index><file_extension>` is what
|
||||
/// `:VimwikiIndex` opens.
|
||||
pub index: String,
|
||||
/// Subdirectory under `root` where diary entries live. Matches
|
||||
/// vimwiki's `g:vimwiki_diary_rel_path` (default `"diary"`).
|
||||
pub diary_rel_path: String,
|
||||
/// Stem of the diary index page within the diary subdirectory.
|
||||
/// Matches vimwiki's `g:vimwiki_diary_index` (default `"diary"`).
|
||||
/// The full path is `<root>/<diary_rel_path>/<diary_index>.wiki`.
|
||||
pub diary_index: String,
|
||||
/// `daily`/`weekly`/`monthly`/`yearly`. Picks the date format the
|
||||
/// diary commands use. `daily` is supported; the others fall through
|
||||
/// to vimwiki-style names but the commands are no-ops for them.
|
||||
pub diary_frequency: String,
|
||||
/// Caption level for the diary index page headings (1 = h1).
|
||||
pub diary_caption_level: u8,
|
||||
/// Newest-first (`desc`) or oldest-first (`asc`) in the diary
|
||||
/// index.
|
||||
pub diary_sort: String,
|
||||
/// H1 text for the diary index page.
|
||||
pub diary_header: String,
|
||||
/// Vimwiki's `g:vimwiki_listsyms`. A progression of checkbox glyphs
|
||||
/// from "empty" (first) to "done" (last); the lexer recognises these
|
||||
/// glyphs and the toggle/cycle commands walk them. Any length ≥ 2 is
|
||||
/// honoured. Default: `" .oOX"`.
|
||||
pub listsyms: String,
|
||||
/// When toggling a parent list item, also cascade to descendants.
|
||||
pub listsyms_propagate: bool,
|
||||
/// `-1` keeps tight lists; positive integers indent list items by
|
||||
/// that many extra columns. Layout hint for the renderer.
|
||||
pub list_margin: i32,
|
||||
/// Character used in place of literal spaces inside wikilink
|
||||
/// targets when writing to disk (a single space `" "` by default,
|
||||
/// i.e. spaces are kept verbatim).
|
||||
pub links_space_char: String,
|
||||
/// Rebuild the page's TOC on save when set.
|
||||
pub auto_toc: bool,
|
||||
/// HTML export options.
|
||||
pub html: HtmlConfig,
|
||||
}
|
||||
|
||||
/// Per-wiki HTML export settings. All paths are absolute (relative
|
||||
/// paths from `initializationOptions` are resolved against the wiki
|
||||
/// root at config-load time).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HtmlConfig {
|
||||
/// Output directory for `nuwiki.export.*` commands.
|
||||
/// Default: `<root>/_html`.
|
||||
pub html_path: PathBuf,
|
||||
/// Template search root. Default: `<root>/_templates`.
|
||||
pub template_path: PathBuf,
|
||||
/// Template name used when a page has no `%template` directive.
|
||||
pub template_default: String,
|
||||
/// Template file extension appended when looking up a template.
|
||||
pub template_ext: String,
|
||||
/// `%Y`/`%m`/`%d` format string passed to `{{date}}`.
|
||||
pub template_date_format: String,
|
||||
/// CSS file name relative to `html_path` (copied/created if missing).
|
||||
pub css_name: String,
|
||||
/// Run `currentToHtml` automatically on `textDocument/didSave`.
|
||||
pub auto_export: bool,
|
||||
/// When true, the output filename for a page is URL-safe-slugified.
|
||||
pub html_filename_parameterization: bool,
|
||||
/// Glob patterns (matched against the page name relative to root)
|
||||
/// to skip during `allToHtml*`.
|
||||
pub exclude_files: Vec<String>,
|
||||
/// `color_dic`: vimwiki colour-tag name → CSS value mapping used
|
||||
/// by the HTML renderer. Empty by default; the
|
||||
/// renderer falls back to `class="color-<name>"` when a name
|
||||
/// isn't in the dict.
|
||||
pub color_dic: std::collections::HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl Default for HtmlConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
html_path: PathBuf::new(),
|
||||
template_path: PathBuf::new(),
|
||||
template_default: "default".into(),
|
||||
template_ext: ".tpl".into(),
|
||||
template_date_format: "%Y-%m-%d".into(),
|
||||
css_name: "style.css".into(),
|
||||
auto_export: false,
|
||||
html_filename_parameterization: false,
|
||||
exclude_files: Vec::new(),
|
||||
color_dic: std::collections::HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl HtmlConfig {
|
||||
/// Synthesise default paths under `root` when the user hasn't
|
||||
/// specified explicit ones. `html_path` defaults to `<root>/_html`
|
||||
/// and `template_path` to `<root>/_templates`.
|
||||
pub fn from_root(root: &Path) -> Self {
|
||||
Self {
|
||||
html_path: root.join("_html"),
|
||||
template_path: root.join("_templates"),
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct DiagnosticConfig {
|
||||
pub link_severity: LinkSeverity,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub enum LinkSeverity {
|
||||
Off,
|
||||
Hint,
|
||||
#[default]
|
||||
Warning,
|
||||
Error,
|
||||
}
|
||||
|
||||
impl LinkSeverity {
|
||||
/// Parse a client-supplied severity string. Case-insensitive;
|
||||
/// accepts both `warn` and `warning`. Returns `None` for unknown
|
||||
/// values so callers can fall back to the default.
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
match s.trim().to_ascii_lowercase().as_str() {
|
||||
"off" => Some(Self::Off),
|
||||
"hint" => Some(Self::Hint),
|
||||
"warn" | "warning" => Some(Self::Warning),
|
||||
"error" => Some(Self::Error),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl WikiConfig {
|
||||
/// Synthetic default used when the client supplies neither
|
||||
/// `initializationOptions` nor a workspace root. `name` is empty;
|
||||
/// callers shouldn't depend on it for routing.
|
||||
pub fn empty() -> Self {
|
||||
let d = wiki_defaults();
|
||||
Self {
|
||||
name: String::new(),
|
||||
root: PathBuf::new(),
|
||||
file_extension: ".wiki".into(),
|
||||
syntax: "vimwiki".into(),
|
||||
index: d.index,
|
||||
diary_rel_path: d.diary_rel_path,
|
||||
diary_index: d.diary_index,
|
||||
diary_frequency: d.diary_frequency,
|
||||
diary_caption_level: d.diary_caption_level,
|
||||
diary_sort: d.diary_sort,
|
||||
diary_header: d.diary_header,
|
||||
listsyms: d.listsyms,
|
||||
listsyms_propagate: d.listsyms_propagate,
|
||||
list_margin: d.list_margin,
|
||||
links_space_char: d.links_space_char,
|
||||
auto_toc: d.auto_toc,
|
||||
html: HtmlConfig::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_root(root: PathBuf) -> Self {
|
||||
let html = HtmlConfig::from_root(&root);
|
||||
let d = wiki_defaults();
|
||||
Self {
|
||||
name: root
|
||||
.file_name()
|
||||
.map(|s| s.to_string_lossy().into_owned())
|
||||
.unwrap_or_default(),
|
||||
root,
|
||||
file_extension: ".wiki".into(),
|
||||
syntax: "vimwiki".into(),
|
||||
index: d.index,
|
||||
diary_rel_path: d.diary_rel_path,
|
||||
diary_index: d.diary_index,
|
||||
diary_frequency: d.diary_frequency,
|
||||
diary_caption_level: d.diary_caption_level,
|
||||
diary_sort: d.diary_sort,
|
||||
diary_header: d.diary_header,
|
||||
listsyms: d.listsyms,
|
||||
listsyms_propagate: d.listsyms_propagate,
|
||||
list_margin: d.list_margin,
|
||||
links_space_char: d.links_space_char,
|
||||
auto_toc: d.auto_toc,
|
||||
html,
|
||||
}
|
||||
}
|
||||
|
||||
/// Absolute path to the diary subdirectory under this wiki's root.
|
||||
pub fn diary_dir(&self) -> PathBuf {
|
||||
self.root.join(&self.diary_rel_path)
|
||||
}
|
||||
|
||||
/// Absolute path to this wiki's diary index page.
|
||||
pub fn diary_index_path(&self) -> PathBuf {
|
||||
let mut p = self.diary_dir();
|
||||
p.push(format!("{}{}", self.diary_index, self.file_extension));
|
||||
p
|
||||
}
|
||||
|
||||
/// Absolute path for a diary entry on `date`.
|
||||
pub fn diary_path_for(&self, date: &nuwiki_core::date::DiaryDate) -> PathBuf {
|
||||
let mut p = self.diary_dir();
|
||||
p.push(format!("{}{}", date.format(), self.file_extension));
|
||||
p
|
||||
}
|
||||
|
||||
/// Absolute path for a diary entry covering `period`. Honours the
|
||||
/// wiki's `diary_frequency` indirectly — callers compute the period
|
||||
/// at the right cadence and we just pick the stem from its format.
|
||||
pub fn diary_path_for_period(&self, period: &nuwiki_core::date::DiaryPeriod) -> PathBuf {
|
||||
let mut p = self.diary_dir();
|
||||
p.push(format!("{}{}", period.format(), self.file_extension));
|
||||
p
|
||||
}
|
||||
|
||||
/// `DiaryFrequency` parsed from this wiki's `diary_frequency` field.
|
||||
pub fn frequency(&self) -> nuwiki_core::date::DiaryFrequency {
|
||||
nuwiki_core::date::DiaryFrequency::parse(&self.diary_frequency)
|
||||
}
|
||||
}
|
||||
|
||||
fn default_diary_rel_path() -> String {
|
||||
"diary".to_string()
|
||||
}
|
||||
|
||||
fn default_diary_index() -> String {
|
||||
"diary".to_string()
|
||||
}
|
||||
|
||||
fn default_index() -> String {
|
||||
"index".to_string()
|
||||
}
|
||||
|
||||
fn default_diary_frequency() -> String {
|
||||
"daily".to_string()
|
||||
}
|
||||
|
||||
fn default_diary_sort() -> String {
|
||||
"desc".to_string()
|
||||
}
|
||||
|
||||
fn default_diary_header() -> String {
|
||||
"Diary".to_string()
|
||||
}
|
||||
|
||||
fn default_listsyms() -> String {
|
||||
" .oOX".to_string()
|
||||
}
|
||||
|
||||
fn default_links_space_char() -> String {
|
||||
" ".to_string()
|
||||
}
|
||||
|
||||
/// Fill in the per-wiki keys that none of the constructors care
|
||||
/// about. Centralising the defaults here keeps `empty()`, `from_root()`,
|
||||
/// the legacy single-wiki path, and `From<RawWiki>` in sync.
|
||||
fn wiki_defaults() -> WikiDefaults {
|
||||
WikiDefaults {
|
||||
index: default_index(),
|
||||
diary_rel_path: default_diary_rel_path(),
|
||||
diary_index: default_diary_index(),
|
||||
diary_frequency: default_diary_frequency(),
|
||||
diary_caption_level: 1,
|
||||
diary_sort: default_diary_sort(),
|
||||
diary_header: default_diary_header(),
|
||||
listsyms: default_listsyms(),
|
||||
listsyms_propagate: true,
|
||||
list_margin: -1,
|
||||
links_space_char: default_links_space_char(),
|
||||
auto_toc: false,
|
||||
}
|
||||
}
|
||||
|
||||
struct WikiDefaults {
|
||||
index: String,
|
||||
diary_rel_path: String,
|
||||
diary_index: String,
|
||||
diary_frequency: String,
|
||||
diary_caption_level: u8,
|
||||
diary_sort: String,
|
||||
diary_header: String,
|
||||
listsyms: String,
|
||||
listsyms_propagate: bool,
|
||||
list_margin: i32,
|
||||
links_space_char: String,
|
||||
auto_toc: bool,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// Build a `Config` from `InitializeParams`.
|
||||
///
|
||||
/// Priority for the wikis list:
|
||||
/// 1. `initialization_options.wikis = [...]` (multi-wiki shape)
|
||||
/// 2. `initialization_options.wiki_root + file_extension + syntax`
|
||||
/// (legacy single-wiki shape)
|
||||
/// 3. `workspace_folders[0]` if present
|
||||
/// 4. deprecated `root_uri` if present
|
||||
/// 5. empty list (server stays alive but won't index anything)
|
||||
pub fn from_init_params(params: &InitializeParams) -> Self {
|
||||
let raw = params
|
||||
.initialization_options
|
||||
.as_ref()
|
||||
.and_then(|v| serde_json::from_value::<InitOptions>(v.clone()).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
let wikis = if let Some(list) = raw.wikis {
|
||||
list.into_iter().map(WikiConfig::from).collect()
|
||||
} else if let Some(root) = raw.wiki_root.as_deref().and_then(expand_path) {
|
||||
let mut wc = WikiConfig::from_root(root);
|
||||
if let Some(ext) = raw.file_extension {
|
||||
wc.file_extension = ext;
|
||||
}
|
||||
if let Some(s) = raw.syntax {
|
||||
wc.syntax = s;
|
||||
}
|
||||
vec![wc]
|
||||
} else if let Some(folder) = first_workspace_folder(params) {
|
||||
vec![WikiConfig::from_root(folder)]
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
Config {
|
||||
wikis,
|
||||
diagnostic: diagnostic_from_raw(&raw.diagnostic),
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-apply settings received via `workspace/didChangeConfiguration`.
|
||||
/// On parse failure the existing config is returned unchanged.
|
||||
///
|
||||
/// Accepts both the flat shape (`{ "wikis": [...] }`) produced by the
|
||||
/// VimL client, **and** the Neovim-style wrapper (`{ "nuwiki": { ... } }`)
|
||||
/// that `server_settings()` in `lua/nuwiki/lsp.lua` produces. Any other
|
||||
/// single-key object whose value is an object is also unwrapped so
|
||||
/// editors that namespace settings by server name continue to work.
|
||||
pub fn apply_change(&mut self, value: &serde_json::Value) {
|
||||
// Unwrap { "nuwiki": { ... } } → { ... } so both clients work.
|
||||
// Only unwrap when the lone key isn't itself a recognised top-level
|
||||
// setting — otherwise a minimal payload like
|
||||
// `{ "diagnostic": { ... } }` would be mistaken for a namespace
|
||||
// wrapper and its contents silently dropped.
|
||||
let inner = value
|
||||
.as_object()
|
||||
.and_then(|m| if m.len() == 1 { m.iter().next() } else { None })
|
||||
.filter(|(k, v)| v.is_object() && !is_init_option_key(k))
|
||||
.map(|(_, v)| v)
|
||||
.unwrap_or(value);
|
||||
|
||||
let raw: InitOptions = match serde_json::from_value(inner.clone()) {
|
||||
Ok(r) => r,
|
||||
Err(_) => return,
|
||||
};
|
||||
if let Some(list) = raw.wikis {
|
||||
self.wikis = list.into_iter().map(WikiConfig::from).collect();
|
||||
} else if let Some(root) = raw.wiki_root.as_deref().and_then(expand_path) {
|
||||
let mut wc = WikiConfig::from_root(root);
|
||||
if let Some(ext) = raw.file_extension {
|
||||
wc.file_extension = ext;
|
||||
}
|
||||
if let Some(s) = raw.syntax {
|
||||
wc.syntax = s;
|
||||
}
|
||||
self.wikis = vec![wc];
|
||||
}
|
||||
// Only touch diagnostic settings when the payload carries them, so a
|
||||
// partial `didChangeConfiguration` doesn't reset severity to default.
|
||||
if raw.diagnostic.is_some() {
|
||||
self.diagnostic = diagnostic_from_raw(&raw.diagnostic);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Wire schema =====
|
||||
|
||||
/// Custom serde helper: accept JSON `true`/`false` **or** integers `0`/`1`
|
||||
/// for `Option<bool>` fields. VimL dicts serialise `auto_export = 1` as
|
||||
/// the JSON number `1`, not `true`, which would otherwise silently break
|
||||
/// deserialisation of the entire `RawWiki` and cause the server to fall
|
||||
/// back to `wiki_root` (ignoring the wikis list and marking every link
|
||||
/// broken).
|
||||
mod opt_bool_or_int {
|
||||
use serde::{Deserialize, Deserializer};
|
||||
|
||||
pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<bool>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let v = Option::<serde_json::Value>::deserialize(deserializer)?;
|
||||
match v {
|
||||
None | Some(serde_json::Value::Null) => Ok(None),
|
||||
Some(serde_json::Value::Bool(b)) => Ok(Some(b)),
|
||||
Some(serde_json::Value::Number(ref n)) => {
|
||||
if let Some(i) = n.as_i64() {
|
||||
Ok(Some(i != 0))
|
||||
} else {
|
||||
Err(serde::de::Error::custom(
|
||||
"expected bool or 0/1 integer for bool field",
|
||||
))
|
||||
}
|
||||
}
|
||||
Some(other) => Err(serde::de::Error::custom(format!(
|
||||
"expected bool or 0/1 integer, got {other}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Recognised top-level keys in the flat `initializationOptions` shape.
|
||||
/// Used to tell a real single-key payload (e.g. `{ "diagnostic": {…} }`)
|
||||
/// apart from an editor namespace wrapper (e.g. `{ "nuwiki": {…} }`) in
|
||||
/// [`Config::apply_change`].
|
||||
fn is_init_option_key(key: &str) -> bool {
|
||||
matches!(
|
||||
key,
|
||||
"wikis" | "wiki_root" | "file_extension" | "syntax" | "log_level" | "diagnostic"
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(default, rename_all = "snake_case")]
|
||||
struct InitOptions {
|
||||
wikis: Option<Vec<RawWiki>>,
|
||||
// legacy single-wiki compat fields
|
||||
wiki_root: Option<String>,
|
||||
file_extension: Option<String>,
|
||||
syntax: Option<String>,
|
||||
log_level: Option<String>,
|
||||
diagnostic: Option<RawDiagnostic>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
struct RawDiagnostic {
|
||||
#[serde(default)]
|
||||
link_severity: Option<String>,
|
||||
}
|
||||
|
||||
/// Build a [`DiagnosticConfig`] from the raw client payload, falling back
|
||||
/// to defaults for absent or unrecognised values.
|
||||
fn diagnostic_from_raw(raw: &Option<RawDiagnostic>) -> DiagnosticConfig {
|
||||
let link_severity = raw
|
||||
.as_ref()
|
||||
.and_then(|d| d.link_severity.as_deref())
|
||||
.and_then(LinkSeverity::parse)
|
||||
.unwrap_or_default();
|
||||
DiagnosticConfig { link_severity }
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
struct RawWiki {
|
||||
#[serde(default)]
|
||||
name: Option<String>,
|
||||
root: String,
|
||||
#[serde(default)]
|
||||
file_extension: Option<String>,
|
||||
#[serde(default)]
|
||||
syntax: Option<String>,
|
||||
#[serde(default)]
|
||||
diary_rel_path: Option<String>,
|
||||
#[serde(default)]
|
||||
diary_index: Option<String>,
|
||||
// HTML keys — all optional, fall back to per-root defaults.
|
||||
#[serde(default)]
|
||||
html_path: Option<String>,
|
||||
#[serde(default)]
|
||||
template_path: Option<String>,
|
||||
#[serde(default)]
|
||||
template_default: Option<String>,
|
||||
#[serde(default)]
|
||||
template_ext: Option<String>,
|
||||
#[serde(default)]
|
||||
template_date_format: Option<String>,
|
||||
#[serde(default)]
|
||||
css_name: Option<String>,
|
||||
#[serde(default, deserialize_with = "opt_bool_or_int::deserialize")]
|
||||
auto_export: Option<bool>,
|
||||
#[serde(default, deserialize_with = "opt_bool_or_int::deserialize")]
|
||||
html_filename_parameterization: Option<bool>,
|
||||
#[serde(default)]
|
||||
exclude_files: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
color_dic: Option<std::collections::HashMap<String, String>>,
|
||||
// per-wiki keys mirroring vimwiki globals. All optional so
|
||||
// existing single-wiki configs migrate without touching anything.
|
||||
#[serde(default)]
|
||||
index: Option<String>,
|
||||
#[serde(default)]
|
||||
diary_frequency: Option<String>,
|
||||
#[serde(default)]
|
||||
diary_caption_level: Option<u8>,
|
||||
#[serde(default)]
|
||||
diary_sort: Option<String>,
|
||||
#[serde(default)]
|
||||
diary_header: Option<String>,
|
||||
#[serde(default)]
|
||||
listsyms: Option<String>,
|
||||
#[serde(default, deserialize_with = "opt_bool_or_int::deserialize")]
|
||||
listsyms_propagate: Option<bool>,
|
||||
#[serde(default)]
|
||||
list_margin: Option<i32>,
|
||||
#[serde(default)]
|
||||
links_space_char: Option<String>,
|
||||
#[serde(default, deserialize_with = "opt_bool_or_int::deserialize")]
|
||||
auto_toc: Option<bool>,
|
||||
}
|
||||
|
||||
impl From<RawWiki> for WikiConfig {
|
||||
fn from(r: RawWiki) -> Self {
|
||||
let root = expand_path(&r.root).unwrap_or_default();
|
||||
let derived_name = root
|
||||
.file_name()
|
||||
.map(|s| s.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
let mut html = HtmlConfig::from_root(&root);
|
||||
if let Some(p) = r.html_path.as_deref().and_then(expand_path) {
|
||||
html.html_path = p;
|
||||
}
|
||||
if let Some(p) = r.template_path.as_deref().and_then(expand_path) {
|
||||
html.template_path = p;
|
||||
}
|
||||
if let Some(s) = r.template_default {
|
||||
html.template_default = s;
|
||||
}
|
||||
if let Some(s) = r.template_ext {
|
||||
html.template_ext = s;
|
||||
}
|
||||
if let Some(s) = r.template_date_format {
|
||||
html.template_date_format = s;
|
||||
}
|
||||
if let Some(s) = r.css_name {
|
||||
html.css_name = s;
|
||||
}
|
||||
if let Some(b) = r.auto_export {
|
||||
html.auto_export = b;
|
||||
}
|
||||
if let Some(b) = r.html_filename_parameterization {
|
||||
html.html_filename_parameterization = b;
|
||||
}
|
||||
if let Some(v) = r.exclude_files {
|
||||
html.exclude_files = v;
|
||||
}
|
||||
if let Some(v) = r.color_dic {
|
||||
html.color_dic = v;
|
||||
}
|
||||
let d = wiki_defaults();
|
||||
WikiConfig {
|
||||
name: r.name.unwrap_or(derived_name),
|
||||
root,
|
||||
file_extension: r.file_extension.unwrap_or_else(|| ".wiki".into()),
|
||||
syntax: r.syntax.unwrap_or_else(|| "vimwiki".into()),
|
||||
index: r.index.unwrap_or(d.index),
|
||||
diary_rel_path: r.diary_rel_path.unwrap_or(d.diary_rel_path),
|
||||
diary_index: r.diary_index.unwrap_or(d.diary_index),
|
||||
diary_frequency: r.diary_frequency.unwrap_or(d.diary_frequency),
|
||||
diary_caption_level: r.diary_caption_level.unwrap_or(d.diary_caption_level),
|
||||
diary_sort: r.diary_sort.unwrap_or(d.diary_sort),
|
||||
diary_header: r.diary_header.unwrap_or(d.diary_header),
|
||||
listsyms: r.listsyms.unwrap_or(d.listsyms),
|
||||
listsyms_propagate: r.listsyms_propagate.unwrap_or(d.listsyms_propagate),
|
||||
list_margin: r.list_margin.unwrap_or(d.list_margin),
|
||||
links_space_char: r.links_space_char.unwrap_or(d.links_space_char),
|
||||
auto_toc: r.auto_toc.unwrap_or(d.auto_toc),
|
||||
html,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn expand_path(s: &str) -> Option<PathBuf> {
|
||||
let trimmed = s.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// Best-effort `~` expansion. Avoids pulling in `dirs` or `home`.
|
||||
if let Some(rest) = trimmed.strip_prefix("~/") {
|
||||
if let Some(home) = std::env::var_os("HOME") {
|
||||
return Some(Path::new(&home).join(rest));
|
||||
}
|
||||
} else if trimmed == "~" {
|
||||
if let Some(home) = std::env::var_os("HOME") {
|
||||
return Some(PathBuf::from(home));
|
||||
}
|
||||
}
|
||||
Some(PathBuf::from(trimmed))
|
||||
}
|
||||
|
||||
fn first_workspace_folder(params: &InitializeParams) -> Option<PathBuf> {
|
||||
if let Some(folders) = ¶ms.workspace_folders {
|
||||
if let Some(first) = folders.first() {
|
||||
return first.uri.to_file_path().ok();
|
||||
}
|
||||
}
|
||||
#[allow(deprecated)]
|
||||
if let Some(uri) = ¶ms.root_uri {
|
||||
return uri.to_file_path().ok();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Test helper: build a `Config` from a JSON value (mimics what arrives
|
||||
/// in `initializationOptions`).
|
||||
pub fn config_from_json(value: serde_json::Value) -> Config {
|
||||
let mut cfg = Config::default();
|
||||
cfg.apply_change(&value);
|
||||
cfg
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn _ensure_url_is_used(_u: &Url) {}
|
||||
@@ -1,364 +0,0 @@
|
||||
//! Diagnostic sources beyond parse-time `ErrorNode`s.
|
||||
//!
|
||||
//! The `nuwiki.link` source emits broken-link warnings. Walks
|
||||
//! every `WikiLinkNode` in the AST and emits one diagnostic per:
|
||||
//! - `Wiki` target that doesn't resolve to a page in the workspace index,
|
||||
//! - `Wiki`/`AnchorOnly` target with an anchor that matches neither a
|
||||
//! heading nor a `:tag:` on the target page,
|
||||
//! - `File`/`Local` target whose resolved path doesn't exist on disk.
|
||||
//!
|
||||
//! Interwiki, Diary, Raw, and external links are not diagnosed —
|
||||
//! interwiki resolution and diary handling live elsewhere, and we
|
||||
//! don't fetch URLs.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use tower_lsp::lsp_types::{Diagnostic, DiagnosticSeverity, Url};
|
||||
|
||||
use nuwiki_core::ast::{
|
||||
BlockNode, BlockquoteNode, DocumentNode, InlineNode, LinkKind, ListItemNode, ListNode,
|
||||
TableNode, WikiLinkNode,
|
||||
};
|
||||
|
||||
use crate::config::LinkSeverity;
|
||||
use crate::index::{OutgoingLink, WorkspaceIndex};
|
||||
use crate::to_lsp_range;
|
||||
|
||||
pub const LINK_SOURCE: &str = "nuwiki.link";
|
||||
|
||||
/// Convert a [`LinkSeverity`] to its LSP counterpart. `Off` returns `None`
|
||||
/// so callers can short-circuit before walking the document.
|
||||
pub fn severity_to_lsp(s: LinkSeverity) -> Option<DiagnosticSeverity> {
|
||||
match s {
|
||||
LinkSeverity::Off => None,
|
||||
LinkSeverity::Hint => Some(DiagnosticSeverity::HINT),
|
||||
LinkSeverity::Warning => Some(DiagnosticSeverity::WARNING),
|
||||
LinkSeverity::Error => Some(DiagnosticSeverity::ERROR),
|
||||
}
|
||||
}
|
||||
|
||||
/// Walk the AST and collect every `WikiLinkNode` in source order.
|
||||
pub fn collect_wiki_links(ast: &DocumentNode) -> Vec<&WikiLinkNode> {
|
||||
let mut out = Vec::new();
|
||||
for block in &ast.children {
|
||||
walk_block(block, &mut out);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn walk_block<'a>(block: &'a BlockNode, out: &mut Vec<&'a WikiLinkNode>) {
|
||||
match block {
|
||||
BlockNode::Heading(h) => walk_inlines(&h.children, out),
|
||||
BlockNode::Paragraph(p) => walk_inlines(&p.children, out),
|
||||
BlockNode::Blockquote(BlockquoteNode { children, .. }) => {
|
||||
for c in children {
|
||||
walk_block(c, out);
|
||||
}
|
||||
}
|
||||
BlockNode::List(ListNode { items, .. }) => {
|
||||
for item in items {
|
||||
walk_list_item(item, out);
|
||||
}
|
||||
}
|
||||
BlockNode::DefinitionList(dl) => {
|
||||
for item in &dl.items {
|
||||
if let Some(t) = &item.term {
|
||||
walk_inlines(t, out);
|
||||
}
|
||||
for d in &item.definitions {
|
||||
walk_inlines(d, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
BlockNode::Table(TableNode { rows, .. }) => {
|
||||
for row in rows {
|
||||
for cell in &row.cells {
|
||||
walk_inlines(&cell.children, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn walk_list_item<'a>(item: &'a ListItemNode, out: &mut Vec<&'a WikiLinkNode>) {
|
||||
walk_inlines(&item.children, out);
|
||||
if let Some(sub) = &item.sublist {
|
||||
for sub_it in &sub.items {
|
||||
walk_list_item(sub_it, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn walk_inlines<'a>(inlines: &'a [InlineNode], out: &mut Vec<&'a WikiLinkNode>) {
|
||||
for n in inlines {
|
||||
match n {
|
||||
InlineNode::WikiLink(w) => {
|
||||
out.push(w);
|
||||
if let Some(d) = &w.description {
|
||||
walk_inlines(d, out);
|
||||
}
|
||||
}
|
||||
InlineNode::Bold(b) => walk_inlines(&b.children, out),
|
||||
InlineNode::Italic(i) => walk_inlines(&i.children, out),
|
||||
InlineNode::BoldItalic(bi) => walk_inlines(&bi.children, out),
|
||||
InlineNode::Strikethrough(s) => walk_inlines(&s.children, out),
|
||||
InlineNode::Superscript(s) => walk_inlines(&s.children, out),
|
||||
InlineNode::Subscript(s) => walk_inlines(&s.children, out),
|
||||
InlineNode::Color(c) => walk_inlines(&c.children, out),
|
||||
InlineNode::ExternalLink(e) => {
|
||||
if let Some(d) = &e.description {
|
||||
walk_inlines(d, out);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Classification of why a single wikilink is broken. Returned by
|
||||
/// [`classify_link`] so callers (diagnostics + `nuwiki.workspace.checkLinks`)
|
||||
/// share the same logic.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum BrokenLinkKind {
|
||||
/// Target page doesn't exist in the workspace index.
|
||||
MissingPage(String),
|
||||
/// Page exists but the `#anchor` doesn't match any heading or `:tag:`.
|
||||
MissingAnchor { page: String, anchor: String },
|
||||
/// `file:` / `local:` target whose resolved path isn't on disk.
|
||||
MissingFile(PathBuf),
|
||||
}
|
||||
|
||||
impl BrokenLinkKind {
|
||||
pub fn message(&self) -> String {
|
||||
match self {
|
||||
BrokenLinkKind::MissingPage(p) => format!("broken wikilink: page '{p}' not in wiki"),
|
||||
BrokenLinkKind::MissingAnchor { page, anchor } => {
|
||||
format!("broken anchor: '#{anchor}' not found on page '{page}'")
|
||||
}
|
||||
BrokenLinkKind::MissingFile(p) => {
|
||||
format!("broken file link: '{}' not found", p.display())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Short machine-readable kind, used in the `checkLinks` JSON output so
|
||||
/// editors can colour-code or filter.
|
||||
pub fn tag(&self) -> &'static str {
|
||||
match self {
|
||||
BrokenLinkKind::MissingPage(_) => "missing-page",
|
||||
BrokenLinkKind::MissingAnchor { .. } => "missing-anchor",
|
||||
BrokenLinkKind::MissingFile(_) => "missing-file",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Inspect one wikilink and return `Some(_)` when it's broken. The `uri`
|
||||
/// is the source document's URI — required for `file:` / `local:`
|
||||
/// resolution. `source_page` is the source's name in the index (used by
|
||||
/// `AnchorOnly` links to look up the current page's headings/tags).
|
||||
pub fn classify_link(
|
||||
link: &WikiLinkNode,
|
||||
uri: Option<&Url>,
|
||||
index: &WorkspaceIndex,
|
||||
source_page: &str,
|
||||
) -> Option<BrokenLinkKind> {
|
||||
match link.target.kind {
|
||||
LinkKind::Wiki => {
|
||||
let path = link.target.path.as_deref()?;
|
||||
match index.page_for_wiki_target(path, source_page, link.target.is_absolute) {
|
||||
None => Some(BrokenLinkKind::MissingPage(path.to_string())),
|
||||
Some(page) => {
|
||||
if let Some(anchor) = &link.target.anchor {
|
||||
if anchor_resolves_on(page, anchor) {
|
||||
None
|
||||
} else {
|
||||
Some(BrokenLinkKind::MissingAnchor {
|
||||
page: path.to_string(),
|
||||
anchor: anchor.clone(),
|
||||
})
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
LinkKind::AnchorOnly => {
|
||||
let anchor = link.target.anchor.as_deref()?;
|
||||
let page = index.page_by_name(source_page)?;
|
||||
if anchor_resolves_on(page, anchor) {
|
||||
None
|
||||
} else {
|
||||
Some(BrokenLinkKind::MissingAnchor {
|
||||
page: source_page.to_string(),
|
||||
anchor: anchor.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
LinkKind::File | LinkKind::Local => {
|
||||
let path = link.target.path.as_deref()?;
|
||||
let resolved = resolve_file_path(uri, index, path, link.target.is_absolute)?;
|
||||
if resolved.exists() {
|
||||
None
|
||||
} else {
|
||||
Some(BrokenLinkKind::MissingFile(resolved))
|
||||
}
|
||||
}
|
||||
LinkKind::Interwiki | LinkKind::Diary | LinkKind::Raw => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn anchor_resolves_on(page: &crate::index::IndexedPage, anchor: &str) -> bool {
|
||||
page.find_heading_by_anchor(anchor).is_some() || page.tags.iter().any(|t| t.name == anchor)
|
||||
}
|
||||
|
||||
/// Same classification as [`classify_link`] but driven from the cached
|
||||
/// [`OutgoingLink`] data so workspace-wide checks don't have to re-parse
|
||||
/// every page. The on-disk `File`/`Local` check is performed when the
|
||||
/// source URI is available.
|
||||
pub fn classify_outgoing(
|
||||
link: &OutgoingLink,
|
||||
source_uri: &Url,
|
||||
index: &WorkspaceIndex,
|
||||
source_page: &str,
|
||||
) -> Option<BrokenLinkKind> {
|
||||
match link.kind {
|
||||
LinkKind::Wiki => {
|
||||
let path = link.target_page.as_deref()?;
|
||||
match index.page_for_wiki_target(path, source_page, link.is_absolute) {
|
||||
None => Some(BrokenLinkKind::MissingPage(path.to_string())),
|
||||
Some(page) => {
|
||||
if let Some(anchor) = &link.anchor {
|
||||
if anchor_resolves_on(page, anchor) {
|
||||
None
|
||||
} else {
|
||||
Some(BrokenLinkKind::MissingAnchor {
|
||||
page: path.to_string(),
|
||||
anchor: anchor.clone(),
|
||||
})
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
LinkKind::AnchorOnly => {
|
||||
let anchor = link.anchor.as_deref()?;
|
||||
let page = index.page_by_name(source_page)?;
|
||||
if anchor_resolves_on(page, anchor) {
|
||||
None
|
||||
} else {
|
||||
Some(BrokenLinkKind::MissingAnchor {
|
||||
page: source_page.to_string(),
|
||||
anchor: anchor.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
LinkKind::File | LinkKind::Local => {
|
||||
let path = link.target_page.as_deref()?;
|
||||
let resolved = resolve_file_path(Some(source_uri), index, path, link.is_absolute)?;
|
||||
if resolved.exists() {
|
||||
None
|
||||
} else {
|
||||
Some(BrokenLinkKind::MissingFile(resolved))
|
||||
}
|
||||
}
|
||||
LinkKind::Interwiki | LinkKind::Diary | LinkKind::Raw => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Flatten heading inlines down to a plain string. Used by `commands::ops`
|
||||
/// to match heading text against the configured TOC/Links section name.
|
||||
pub fn heading_text(inlines: &[InlineNode]) -> String {
|
||||
let mut s = String::new();
|
||||
push_inline_text(inlines, &mut s);
|
||||
s.trim().to_string()
|
||||
}
|
||||
|
||||
fn push_inline_text(inlines: &[InlineNode], out: &mut String) {
|
||||
for n in inlines {
|
||||
match n {
|
||||
InlineNode::Text(t) => out.push_str(&t.content),
|
||||
InlineNode::Bold(b) => push_inline_text(&b.children, out),
|
||||
InlineNode::Italic(i) => push_inline_text(&i.children, out),
|
||||
InlineNode::BoldItalic(bi) => push_inline_text(&bi.children, out),
|
||||
InlineNode::Strikethrough(s) => push_inline_text(&s.children, out),
|
||||
InlineNode::Code(c) => out.push_str(&c.content),
|
||||
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::WikiLink(w) => match &w.description {
|
||||
Some(d) => push_inline_text(d, out),
|
||||
None => {
|
||||
if let Some(p) = &w.target.path {
|
||||
out.push_str(p);
|
||||
}
|
||||
}
|
||||
},
|
||||
InlineNode::ExternalLink(e) => match &e.description {
|
||||
Some(d) => push_inline_text(d, out),
|
||||
None => out.push_str(&e.url),
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a `file:` / `local:` link target to an absolute path.
|
||||
///
|
||||
/// - Absolute (parsed `//path` or `is_absolute`): used as-is.
|
||||
/// - Otherwise: joined with the source file's parent directory.
|
||||
/// - If we don't have a URI to anchor relative paths against, falls back
|
||||
/// to the wiki root from the index.
|
||||
fn resolve_file_path(
|
||||
uri: Option<&Url>,
|
||||
index: &WorkspaceIndex,
|
||||
path: &str,
|
||||
is_absolute: bool,
|
||||
) -> Option<PathBuf> {
|
||||
let candidate = PathBuf::from(path);
|
||||
if is_absolute || candidate.is_absolute() {
|
||||
return Some(candidate);
|
||||
}
|
||||
if let Some(uri) = uri {
|
||||
if let Ok(base) = uri.to_file_path() {
|
||||
if let Some(parent) = base.parent() {
|
||||
return Some(parent.join(&candidate));
|
||||
}
|
||||
}
|
||||
}
|
||||
index.root.as_ref().map(|r| r.join(&candidate))
|
||||
}
|
||||
|
||||
/// `nuwiki.link` diagnostics for a single document.
|
||||
pub fn link_health(
|
||||
ast: &DocumentNode,
|
||||
text: &str,
|
||||
uri: Option<&Url>,
|
||||
index: &WorkspaceIndex,
|
||||
source_page: &str,
|
||||
severity: LinkSeverity,
|
||||
utf8: bool,
|
||||
) -> Vec<Diagnostic> {
|
||||
let Some(sev) = severity_to_lsp(severity) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for link in collect_wiki_links(ast) {
|
||||
if let Some(kind) = classify_link(link, uri, index, source_page) {
|
||||
out.push(Diagnostic {
|
||||
range: to_lsp_range(&link.span, text, utf8),
|
||||
severity: Some(sev),
|
||||
source: Some(LINK_SOURCE.into()),
|
||||
message: kind.message(),
|
||||
code: Some(tower_lsp::lsp_types::NumberOrString::String(
|
||||
kind.tag().to_string(),
|
||||
)),
|
||||
..Diagnostic::default()
|
||||
});
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -1,311 +0,0 @@
|
||||
//! Diary subsystem — pure helpers used by the
|
||||
//! `nuwiki.diary.*` `executeCommand` handlers.
|
||||
//!
|
||||
//! All path/URI math lives here so the command dispatcher stays a thin
|
||||
//! wrapper. The diary navigation model is intentionally lightweight:
|
||||
//! entries are identified by file stem (`YYYY-MM-DD`) living under
|
||||
//! `<wiki_root>/<diary_rel_path>/`. The `WorkspaceIndex` tags each
|
||||
//! `IndexedPage` with a `diary_date` so prev/next/list operations stay
|
||||
//! O(n) over the indexed pages.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use tower_lsp::lsp_types::Url;
|
||||
|
||||
use nuwiki_core::date::{DiaryDate, DiaryPeriod};
|
||||
|
||||
use crate::config::WikiConfig;
|
||||
use crate::index::WorkspaceIndex;
|
||||
|
||||
/// Resolve a `DiaryDate` to its `file://` URI under the given wiki.
|
||||
pub fn uri_for_date(cfg: &WikiConfig, date: &DiaryDate) -> Option<Url> {
|
||||
Url::from_file_path(cfg.diary_path_for(date)).ok()
|
||||
}
|
||||
|
||||
/// Resolve a `DiaryPeriod` to its `file://` URI under the given wiki.
|
||||
/// Picks the right stem for the period's flavour (day / week / month / year).
|
||||
pub fn uri_for_period(cfg: &WikiConfig, period: &DiaryPeriod) -> Option<Url> {
|
||||
Url::from_file_path(cfg.diary_path_for_period(period)).ok()
|
||||
}
|
||||
|
||||
/// `file://` URI of the diary index page.
|
||||
pub fn index_uri(cfg: &WikiConfig) -> Option<Url> {
|
||||
Url::from_file_path(cfg.diary_index_path()).ok()
|
||||
}
|
||||
|
||||
/// All diary entries currently indexed for `wiki`, sorted ascending by
|
||||
/// date. The wiki's index must already have its `diary_rel_path` set
|
||||
/// (handled by `Wiki::new`).
|
||||
pub fn list_entries(index: &WorkspaceIndex) -> Vec<DiaryEntry> {
|
||||
let mut out: Vec<DiaryEntry> = index
|
||||
.pages_by_uri
|
||||
.values()
|
||||
.filter_map(|p| {
|
||||
p.diary_date.map(|d| DiaryEntry {
|
||||
date: d,
|
||||
uri: p.uri.clone(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
out.sort_by(|a, b| a.date.cmp(&b.date));
|
||||
out
|
||||
}
|
||||
|
||||
/// Entries for a given (year, month). `month = None` returns the whole
|
||||
/// year; `year = None` matches every year.
|
||||
pub fn list_entries_filtered(
|
||||
index: &WorkspaceIndex,
|
||||
year: Option<i32>,
|
||||
month: Option<u8>,
|
||||
) -> Vec<DiaryEntry> {
|
||||
list_entries(index)
|
||||
.into_iter()
|
||||
.filter(|e| year.is_none_or(|y| e.date.year == y))
|
||||
.filter(|e| month.is_none_or(|m| e.date.month == m))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The diary entry chronologically after `from`. `from` doesn't need to
|
||||
/// be indexed itself — we look for the smallest indexed date strictly
|
||||
/// greater than `from`.
|
||||
pub fn next_entry(index: &WorkspaceIndex, from: &DiaryDate) -> Option<DiaryEntry> {
|
||||
list_entries(index).into_iter().find(|e| e.date > *from)
|
||||
}
|
||||
|
||||
/// The diary entry chronologically before `from`.
|
||||
pub fn prev_entry(index: &WorkspaceIndex, from: &DiaryDate) -> Option<DiaryEntry> {
|
||||
list_entries(index)
|
||||
.into_iter()
|
||||
.rev()
|
||||
.find(|e| e.date < *from)
|
||||
}
|
||||
|
||||
/// All indexed diary periods of a *specific* flavour (daily / weekly /
|
||||
/// monthly / yearly), sorted ascending. Used by next/prev navigation so
|
||||
/// `<C-Down>` on a weekly page jumps to the next weekly entry, not the
|
||||
/// next daily one.
|
||||
pub fn list_periods_of_kind(index: &WorkspaceIndex, kind: PeriodKind) -> Vec<PeriodEntry> {
|
||||
let mut out: Vec<PeriodEntry> = index
|
||||
.pages_by_uri
|
||||
.values()
|
||||
.filter_map(|p| {
|
||||
let period = p.diary_period?;
|
||||
if PeriodKind::of(&period) != kind {
|
||||
return None;
|
||||
}
|
||||
Some(PeriodEntry {
|
||||
period,
|
||||
uri: p.uri.clone(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
out.sort_by_key(|e| period_first_day(&e.period));
|
||||
out
|
||||
}
|
||||
|
||||
pub fn next_period(index: &WorkspaceIndex, from: &DiaryPeriod) -> Option<PeriodEntry> {
|
||||
let kind = PeriodKind::of(from);
|
||||
let pivot = period_first_day(from);
|
||||
list_periods_of_kind(index, kind)
|
||||
.into_iter()
|
||||
.find(|e| period_first_day(&e.period) > pivot)
|
||||
}
|
||||
|
||||
pub fn prev_period(index: &WorkspaceIndex, from: &DiaryPeriod) -> Option<PeriodEntry> {
|
||||
let kind = PeriodKind::of(from);
|
||||
let pivot = period_first_day(from);
|
||||
list_periods_of_kind(index, kind)
|
||||
.into_iter()
|
||||
.rev()
|
||||
.find(|e| period_first_day(&e.period) < pivot)
|
||||
}
|
||||
|
||||
/// Cluster 4: `(period, uri)` pair for non-daily diary navigation.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PeriodEntry {
|
||||
pub period: DiaryPeriod,
|
||||
pub uri: Url,
|
||||
}
|
||||
|
||||
impl serde::Serialize for PeriodEntry {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
use serde::ser::SerializeStruct;
|
||||
// For back-compat with daily-only clients, emit `date` AND
|
||||
// `period` — the former is just the first-day string of the
|
||||
// period, the latter is the canonical stem.
|
||||
let mut s = serializer.serialize_struct("PeriodEntry", 3)?;
|
||||
s.serialize_field("date", &self.period.first_day().format())?;
|
||||
s.serialize_field("period", &self.period.format())?;
|
||||
s.serialize_field("uri", &self.uri)?;
|
||||
s.end()
|
||||
}
|
||||
}
|
||||
|
||||
/// Which flavour of `DiaryPeriod` we're looking at.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PeriodKind {
|
||||
Day,
|
||||
Week,
|
||||
Month,
|
||||
Year,
|
||||
}
|
||||
|
||||
impl PeriodKind {
|
||||
pub fn of(p: &DiaryPeriod) -> Self {
|
||||
match p {
|
||||
DiaryPeriod::Day(_) => Self::Day,
|
||||
DiaryPeriod::Week { .. } => Self::Week,
|
||||
DiaryPeriod::Month { .. } => Self::Month,
|
||||
DiaryPeriod::Year { .. } => Self::Year,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn period_first_day(p: &DiaryPeriod) -> DiaryDate {
|
||||
p.first_day()
|
||||
}
|
||||
|
||||
/// Ordering of entries in the diary index page.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DiarySort {
|
||||
/// Newest first (vimwiki default).
|
||||
Desc,
|
||||
/// Oldest first.
|
||||
Asc,
|
||||
}
|
||||
|
||||
impl DiarySort {
|
||||
/// Parse a wiki's `diary_sort` field. Anything other than `asc`
|
||||
/// (case-insensitive) is treated as the default `desc`.
|
||||
pub fn parse(s: &str) -> Self {
|
||||
if s.trim().eq_ignore_ascii_case("asc") {
|
||||
Self::Asc
|
||||
} else {
|
||||
Self::Desc
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a heading line at `level` (1 = `= … =`). Clamped to vimwiki's
|
||||
/// 1..=6 range so a stray config value can't emit a 200-equals heading.
|
||||
fn heading(level: u8, text: &str) -> String {
|
||||
let n = level.clamp(1, 6) as usize;
|
||||
let eq = "=".repeat(n);
|
||||
format!("{eq} {text} {eq}")
|
||||
}
|
||||
|
||||
/// Generate the body of the diary index page — a flat list of
|
||||
/// `[[diary/YYYY-MM-DD]]` wikilinks, grouped under year and month
|
||||
/// subheadings so the rendered page mirrors `:VimwikiDiaryGenerateLinks`.
|
||||
///
|
||||
/// `caption_level` sets the level of the top caption; the year and month
|
||||
/// subheadings nest one and two levels below it. `sort` controls whether
|
||||
/// the flat list runs newest- or oldest-first.
|
||||
pub fn build_index_body(
|
||||
entries: &[DiaryEntry],
|
||||
diary_rel_path: &str,
|
||||
index_heading: &str,
|
||||
sort: DiarySort,
|
||||
caption_level: u8,
|
||||
) -> String {
|
||||
let mut out = String::new();
|
||||
out.push_str(&heading(caption_level, index_heading));
|
||||
out.push('\n');
|
||||
|
||||
if entries.is_empty() {
|
||||
return out;
|
||||
}
|
||||
|
||||
let mut sorted: Vec<&DiaryEntry> = entries.iter().collect();
|
||||
sorted.sort_by(|a, b| match sort {
|
||||
DiarySort::Desc => b.date.cmp(&a.date),
|
||||
DiarySort::Asc => a.date.cmp(&b.date),
|
||||
});
|
||||
|
||||
let year_level = caption_level.saturating_add(1);
|
||||
let month_level = caption_level.saturating_add(2);
|
||||
let mut current_year: Option<i32> = None;
|
||||
let mut current_month: Option<u8> = None;
|
||||
for e in sorted {
|
||||
if current_year != Some(e.date.year) {
|
||||
out.push('\n');
|
||||
out.push_str(&heading(year_level, &e.date.year.to_string()));
|
||||
out.push('\n');
|
||||
current_year = Some(e.date.year);
|
||||
current_month = None;
|
||||
}
|
||||
if current_month != Some(e.date.month) {
|
||||
out.push_str(&heading(month_level, month_name(e.date.month)));
|
||||
out.push('\n');
|
||||
current_month = Some(e.date.month);
|
||||
}
|
||||
out.push_str(&format!(
|
||||
"- [[{}/{}]]\n",
|
||||
diary_rel_path.trim_end_matches('/'),
|
||||
e.date.format()
|
||||
));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Render the diary-index body for the given wiki's currently-indexed
|
||||
/// entries. Convenience wrapper around [`build_index_body`] that honours
|
||||
/// the wiki's `diary_header`, `diary_sort`, and `diary_caption_level`.
|
||||
pub fn render_index_body(cfg: &WikiConfig, index: &WorkspaceIndex) -> String {
|
||||
build_index_body(
|
||||
&list_entries(index),
|
||||
&cfg.diary_rel_path,
|
||||
&cfg.diary_header,
|
||||
DiarySort::parse(&cfg.diary_sort),
|
||||
cfg.diary_caption_level,
|
||||
)
|
||||
}
|
||||
|
||||
/// Cheap "is this path under the diary subdir of this wiki" check —
|
||||
/// surface-level, no parse needed.
|
||||
pub fn is_in_diary(cfg: &WikiConfig, path: &Path) -> bool {
|
||||
path.starts_with(cfg.diary_dir())
|
||||
}
|
||||
|
||||
/// One diary entry, returned by listing/navigation queries. The custom
|
||||
/// `Serialize` emits `{ "date": "YYYY-MM-DD", "uri": "file://..." }` so
|
||||
/// the client doesn't have to know about [`DiaryDate`]'s internal layout.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DiaryEntry {
|
||||
pub date: DiaryDate,
|
||||
pub uri: Url,
|
||||
}
|
||||
|
||||
impl serde::Serialize for DiaryEntry {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
use serde::ser::SerializeStruct;
|
||||
let mut s = serializer.serialize_struct("DiaryEntry", 2)?;
|
||||
s.serialize_field("date", &self.date.format())?;
|
||||
s.serialize_field("uri", &self.uri)?;
|
||||
s.end()
|
||||
}
|
||||
}
|
||||
|
||||
fn month_name(m: u8) -> &'static str {
|
||||
match m {
|
||||
1 => "January",
|
||||
2 => "February",
|
||||
3 => "March",
|
||||
4 => "April",
|
||||
5 => "May",
|
||||
6 => "June",
|
||||
7 => "July",
|
||||
8 => "August",
|
||||
9 => "September",
|
||||
10 => "October",
|
||||
11 => "November",
|
||||
12 => "December",
|
||||
_ => "Unknown",
|
||||
}
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
//! Helpers for building `WorkspaceEdit`s.
|
||||
//!
|
||||
//! Rename, list/table edits, and link/TOC generation all return
|
||||
//! `WorkspaceEdit`. The builder centralises the byte-offset → LSP-position
|
||||
//! conversion (UTF-8 or UTF-16, matching whatever was negotiated in
|
||||
//! `initialize`) so each call site doesn't have to redo it.
|
||||
//!
|
||||
//! The builder emits `documentChanges` (with `Operation` for file ops)
|
||||
//! whenever any file op is queued, and falls back to the simpler
|
||||
//! `changes` field for pure text-edit batches — the latter is what older
|
||||
//! LSP 3.15- clients understand.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use tower_lsp::lsp_types::{
|
||||
CreateFile, DeleteFile, DocumentChangeOperation, DocumentChanges, OneOf,
|
||||
OptionalVersionedTextDocumentIdentifier, Position, Range, RenameFile, ResourceOp,
|
||||
TextDocumentEdit, TextEdit, Url, WorkspaceEdit,
|
||||
};
|
||||
|
||||
use nuwiki_core::ast::Span;
|
||||
|
||||
use crate::semantic_tokens::LineIndex;
|
||||
|
||||
/// Replace the source covered by `span` with `replacement`. `text` and
|
||||
/// `utf8` are needed to translate the byte-offset span into the LSP
|
||||
/// `Range` the client expects.
|
||||
pub fn text_edit_replace(span: Span, replacement: String, text: &str, utf8: bool) -> TextEdit {
|
||||
TextEdit {
|
||||
range: span_to_range(span, text, utf8),
|
||||
new_text: replacement,
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert `content` at `pos`. The caller has already converted to LSP
|
||||
/// coordinates — no further conversion is performed here.
|
||||
pub fn text_edit_insert(pos: Position, content: String) -> TextEdit {
|
||||
TextEdit {
|
||||
range: Range {
|
||||
start: pos,
|
||||
end: pos,
|
||||
},
|
||||
new_text: content,
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete the source covered by `span`.
|
||||
pub fn text_edit_delete(span: Span, text: &str, utf8: bool) -> TextEdit {
|
||||
text_edit_replace(span, String::new(), text, utf8)
|
||||
}
|
||||
|
||||
pub fn op_rename(old: Url, new: Url) -> DocumentChangeOperation {
|
||||
DocumentChangeOperation::Op(ResourceOp::Rename(RenameFile {
|
||||
old_uri: old,
|
||||
new_uri: new,
|
||||
options: None,
|
||||
annotation_id: None,
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn op_delete(uri: Url) -> DocumentChangeOperation {
|
||||
// lsp-types 0.94.x ships `DeleteFile` without `annotation_id`; later
|
||||
// versions added it. Stick with the 0.94 shape until we move tower-lsp.
|
||||
DocumentChangeOperation::Op(ResourceOp::Delete(DeleteFile { uri, options: None }))
|
||||
}
|
||||
|
||||
pub fn op_create(uri: Url) -> DocumentChangeOperation {
|
||||
DocumentChangeOperation::Op(ResourceOp::Create(CreateFile {
|
||||
uri,
|
||||
options: None,
|
||||
annotation_id: None,
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct WorkspaceEditBuilder {
|
||||
changes: HashMap<Url, Vec<TextEdit>>,
|
||||
ops: Vec<DocumentChangeOperation>,
|
||||
}
|
||||
|
||||
impl WorkspaceEditBuilder {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn edit(&mut self, uri: Url, edit: TextEdit) -> &mut Self {
|
||||
self.changes.entry(uri).or_default().push(edit);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn edits<I>(&mut self, uri: Url, edits: I) -> &mut Self
|
||||
where
|
||||
I: IntoIterator<Item = TextEdit>,
|
||||
{
|
||||
self.changes.entry(uri).or_default().extend(edits);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn file_op(&mut self, op: DocumentChangeOperation) -> &mut Self {
|
||||
self.ops.push(op);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.changes.is_empty() && self.ops.is_empty()
|
||||
}
|
||||
|
||||
pub fn build(self) -> WorkspaceEdit {
|
||||
// documentChanges is required whenever there are file ops (LSP
|
||||
// doesn't represent renames/deletes/creates in the flat `changes`
|
||||
// map). For pure text edits the older `changes` form keeps
|
||||
// compatibility with LSP 3.15-clients.
|
||||
if self.ops.is_empty() {
|
||||
return WorkspaceEdit {
|
||||
changes: Some(self.changes),
|
||||
document_changes: None,
|
||||
change_annotations: None,
|
||||
};
|
||||
}
|
||||
|
||||
let mut document_changes: Vec<DocumentChangeOperation> = self.ops;
|
||||
// Edits land after file ops so the file exists when the text edit
|
||||
// applies (e.g. create + initial content).
|
||||
for (uri, edits) in self.changes {
|
||||
document_changes.push(DocumentChangeOperation::Edit(TextDocumentEdit {
|
||||
text_document: OptionalVersionedTextDocumentIdentifier { uri, version: None },
|
||||
edits: edits.into_iter().map(OneOf::Left).collect(),
|
||||
}));
|
||||
}
|
||||
WorkspaceEdit {
|
||||
changes: None,
|
||||
document_changes: Some(DocumentChanges::Operations(document_changes)),
|
||||
change_annotations: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn span_to_range(span: Span, text: &str, utf8: bool) -> Range {
|
||||
let idx = LineIndex::new(text);
|
||||
Range {
|
||||
start: Position {
|
||||
line: span.start.line,
|
||||
character: char_at(span.start.line, span.start.column, text, &idx, utf8),
|
||||
},
|
||||
end: Position {
|
||||
line: span.end.line,
|
||||
character: char_at(span.end.line, span.end.column, text, &idx, utf8),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn char_at(line: u32, byte_col: u32, text: &str, idx: &LineIndex, utf8: bool) -> u32 {
|
||||
if utf8 {
|
||||
return byte_col;
|
||||
}
|
||||
let line_str = idx.line_str(line, text);
|
||||
let upto = (byte_col as usize).min(line_str.len());
|
||||
line_str[..upto].chars().map(char::len_utf16).sum::<usize>() as u32
|
||||
}
|
||||
@@ -1,423 +0,0 @@
|
||||
//! HTML export — pure helpers shared by the `nuwiki.export.*`
|
||||
//! command handlers. Side-effecting work (reading the template from
|
||||
//! disk, writing the rendered HTML, copying CSS) lives in `commands.rs`
|
||||
//! since the dispatcher is the one with a `&Backend` and async context.
|
||||
//!
|
||||
//! Design split:
|
||||
//! - [`output_path_for`] / [`output_url_for`] — name → on-disk path.
|
||||
//! - [`template_for`] — locate the right template file on disk.
|
||||
//! - [`fallback_template`] — minimal stand-in when nothing is found.
|
||||
//! - [`format_date`] — strftime subset (`%Y`/`%m`/`%d`).
|
||||
//! - [`build_toc_html`] — TOC rendered into the `{{toc}}` variable.
|
||||
//! - [`compute_root_path`] — relative path from a page's output to
|
||||
//! `html_path` for stylesheet hrefs in nested pages.
|
||||
//! - [`is_excluded`] — glob match against `exclude_files`.
|
||||
//! - [`render_page_html`] — drives [`HtmlRenderer`] with all of the
|
||||
//! above wired up; returns the final HTML string.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use nuwiki_core::ast::{BlockNode, DocumentNode, HeadingNode, InlineNode, LinkKind};
|
||||
use nuwiki_core::date::DiaryDate;
|
||||
use nuwiki_core::render::{HtmlRenderer, Renderer};
|
||||
|
||||
use crate::config::{HtmlConfig, WikiConfig};
|
||||
|
||||
/// Map a page name (e.g. `"diary/2026-05-11"`) to its on-disk HTML
|
||||
/// output path under `html_path`. Honours
|
||||
/// [`HtmlConfig::html_filename_parameterization`] for URL-safe slugs
|
||||
/// — when enabled, only the final path segment is slugified (so the
|
||||
/// `diary/` subdir survives).
|
||||
pub fn output_path_for(cfg: &HtmlConfig, name: &str) -> PathBuf {
|
||||
let segments: Vec<String> = name
|
||||
.split('/')
|
||||
.map(|s| {
|
||||
if cfg.html_filename_parameterization {
|
||||
slugify(s)
|
||||
} else {
|
||||
s.to_string()
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let mut p = cfg.html_path.clone();
|
||||
for (i, seg) in segments.iter().enumerate() {
|
||||
if i + 1 == segments.len() {
|
||||
p.push(format!("{seg}.html"));
|
||||
} else {
|
||||
p.push(seg);
|
||||
}
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
/// Locate the template body for a document. Resolution order:
|
||||
/// 1. `<template_path>/<doc.metadata.template>.<template_ext>` if set.
|
||||
/// 2. `<template_path>/<template_default>.<template_ext>`.
|
||||
/// 3. [`fallback_template`].
|
||||
///
|
||||
/// The on-disk read is performed by the caller (`commands.rs`) via the
|
||||
/// [`TemplateSource`] returned here. Keeps this fn pure for tests.
|
||||
pub fn template_for(cfg: &HtmlConfig, doc: &DocumentNode) -> TemplateSource {
|
||||
let primary = doc.metadata.template.as_deref().map(|name| {
|
||||
cfg.template_path
|
||||
.join(format!("{name}{}", cfg.template_ext))
|
||||
});
|
||||
let fallback = cfg
|
||||
.template_path
|
||||
.join(format!("{}{}", cfg.template_default, cfg.template_ext));
|
||||
TemplateSource { primary, fallback }
|
||||
}
|
||||
|
||||
/// Two-tier lookup result. Caller tries `primary` first, then `fallback`,
|
||||
/// then falls back to [`fallback_template`] when neither file exists.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TemplateSource {
|
||||
pub primary: Option<PathBuf>,
|
||||
pub fallback: PathBuf,
|
||||
}
|
||||
|
||||
/// Minimal valid HTML page used when no template file is found on disk.
|
||||
/// Includes the same `{{title}}` / `{{content}}` placeholders as a real
|
||||
/// template so the renderer's substitution still works.
|
||||
pub fn fallback_template(css_name: &str) -> String {
|
||||
format!(
|
||||
r#"<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>{{{{title}}}}</title>
|
||||
<link rel="stylesheet" href="{{{{root_path}}}}{css_name}">
|
||||
</head>
|
||||
<body>
|
||||
{{{{content}}}}
|
||||
</body>
|
||||
</html>
|
||||
"#
|
||||
)
|
||||
}
|
||||
|
||||
/// Tiny strftime: supports `%Y` (4-digit year), `%m` (2-digit month),
|
||||
/// `%d` (2-digit day), `%%` (literal percent). Anything else passes
|
||||
/// through. The diary use case never produces time-of-day output, so
|
||||
/// `%H`/`%M`/`%S` are intentionally omitted.
|
||||
pub fn format_date(date: &DiaryDate, fmt: &str) -> String {
|
||||
let mut out = String::with_capacity(fmt.len() + 4);
|
||||
let bytes = fmt.as_bytes();
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
if bytes[i] == b'%' && i + 1 < bytes.len() {
|
||||
match bytes[i + 1] {
|
||||
b'Y' => out.push_str(&format!("{:04}", date.year)),
|
||||
b'm' => out.push_str(&format!("{:02}", date.month)),
|
||||
b'd' => out.push_str(&format!("{:02}", date.day)),
|
||||
b'%' => out.push('%'),
|
||||
other => {
|
||||
out.push('%');
|
||||
out.push(other as char);
|
||||
}
|
||||
}
|
||||
i += 2;
|
||||
} else {
|
||||
out.push(bytes[i] as char);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Render the page's headings as a nested `<ul>` for the `{{toc}}`
|
||||
/// template variable. Returns an empty string when the document has
|
||||
/// no headings.
|
||||
pub fn build_toc_html(doc: &DocumentNode) -> String {
|
||||
let headings = collect_headings(doc);
|
||||
if headings.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
let min_level = headings.iter().map(|h| h.level).min().unwrap_or(1);
|
||||
let mut out = String::from("<ul class=\"toc\">");
|
||||
let mut depth = 0usize;
|
||||
for h in &headings {
|
||||
let target = h.level.saturating_sub(min_level) as usize;
|
||||
while depth < target {
|
||||
out.push_str("<ul>");
|
||||
depth += 1;
|
||||
}
|
||||
while depth > target {
|
||||
out.push_str("</ul>");
|
||||
depth -= 1;
|
||||
}
|
||||
let anchor = crate::index::slugify(&h.title);
|
||||
out.push_str(&format!(
|
||||
"<li><a href=\"#{anchor}\">{title}</a></li>",
|
||||
title = escape_html(&h.title)
|
||||
));
|
||||
}
|
||||
while depth > 0 {
|
||||
out.push_str("</ul>");
|
||||
depth -= 1;
|
||||
}
|
||||
out.push_str("</ul>");
|
||||
out
|
||||
}
|
||||
|
||||
/// Compute the relative path back to `html_path` from the directory
|
||||
/// holding the output file for `name`. Used as the `{{root_path}}`
|
||||
/// prefix so a nested page's `<link>`/`<script>` hrefs still find
|
||||
/// the shared CSS at the export root.
|
||||
pub fn compute_root_path(name: &str) -> String {
|
||||
let depth = name.matches('/').count();
|
||||
if depth == 0 {
|
||||
String::new()
|
||||
} else {
|
||||
let mut s = String::with_capacity(depth * 3);
|
||||
for _ in 0..depth {
|
||||
s.push_str("../");
|
||||
}
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
/// True if `name` matches any of the `exclude_files` glob patterns.
|
||||
/// Supported wildcards: `*` (any segment chars except `/`), `**` (any
|
||||
/// segments including `/`), `?` (one char). Anything else is literal.
|
||||
pub fn is_excluded(patterns: &[String], name: &str) -> bool {
|
||||
patterns.iter().any(|p| glob_match(p, name))
|
||||
}
|
||||
|
||||
/// Pure HTML render pass. The caller has already loaded the template
|
||||
/// (or falls back). Wires up `{{date}}`, `{{root_path}}`, `{{toc}}`
|
||||
/// plus arbitrary extras passed via `extra_vars`.
|
||||
///
|
||||
/// `wiki_extension` is the wiki's source file extension (e.g. `".wiki"`).
|
||||
/// When set, it is stripped from wiki/interwiki link targets so a link
|
||||
/// written with the extension (`[[todo.wiki]]`) exports to `todo.html`,
|
||||
/// matching how the LSP resolves the same link for in-editor navigation.
|
||||
// Every parameter is a distinct, cohesive render input; bundling them into a
|
||||
// struct would add an abstraction without simplifying the single caller.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn render_page_html(
|
||||
doc: &DocumentNode,
|
||||
template: Option<String>,
|
||||
name: &str,
|
||||
today: DiaryDate,
|
||||
cfg: &HtmlConfig,
|
||||
wiki_extension: Option<&str>,
|
||||
list_margin: i32,
|
||||
extra_vars: HashMap<String, String>,
|
||||
) -> std::io::Result<String> {
|
||||
let date_str = match doc.metadata.date.as_deref() {
|
||||
Some(s) => match DiaryDate::parse(s) {
|
||||
Some(d) => format_date(&d, &cfg.template_date_format),
|
||||
None => s.to_string(),
|
||||
},
|
||||
None => format_date(&today, &cfg.template_date_format),
|
||||
};
|
||||
let root_path = compute_root_path(name);
|
||||
let toc = build_toc_html(doc);
|
||||
|
||||
let mut vars: HashMap<String, String> = HashMap::new();
|
||||
vars.insert("date".into(), date_str);
|
||||
vars.insert("root_path".into(), root_path);
|
||||
vars.insert("toc".into(), toc);
|
||||
vars.insert("css".into(), cfg.css_name.clone());
|
||||
// vimwiki names the stylesheet placeholder `%wiki_css%`; alias it so stock
|
||||
// templates resolve. nuwiki's own templates use `{{css}}`.
|
||||
vars.insert("wiki_css".into(), cfg.css_name.clone());
|
||||
for (k, v) in extra_vars {
|
||||
vars.insert(k, v);
|
||||
}
|
||||
|
||||
let mut r = HtmlRenderer::new().with_list_margin(list_margin);
|
||||
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 = ext.to_string();
|
||||
r = r.with_link_resolver(move |target| {
|
||||
let mut t = target.clone();
|
||||
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());
|
||||
}
|
||||
}
|
||||
nuwiki_core::render::html::default_link_resolver(&t)
|
||||
});
|
||||
}
|
||||
if let Some(t) = template {
|
||||
r = r.with_template(t);
|
||||
}
|
||||
r = r.with_vars(vars);
|
||||
if !cfg.color_dic.is_empty() {
|
||||
r = r.with_colors(cfg.color_dic.clone());
|
||||
}
|
||||
r.render_to_string(doc)
|
||||
}
|
||||
|
||||
// ===== Internals =====
|
||||
|
||||
fn slugify(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut prev_dash = false;
|
||||
for ch in s.chars() {
|
||||
if ch.is_alphanumeric() {
|
||||
for c in ch.to_lowercase() {
|
||||
out.push(c);
|
||||
}
|
||||
prev_dash = false;
|
||||
} else if !prev_dash && !out.is_empty() {
|
||||
out.push('-');
|
||||
prev_dash = true;
|
||||
}
|
||||
}
|
||||
while out.ends_with('-') {
|
||||
out.pop();
|
||||
}
|
||||
if out.is_empty() {
|
||||
"untitled".into()
|
||||
} else {
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_headings(doc: &DocumentNode) -> Vec<HeadingProj> {
|
||||
let mut out = Vec::new();
|
||||
for b in &doc.children {
|
||||
if let BlockNode::Heading(h) = b {
|
||||
out.push(HeadingProj {
|
||||
level: h.level,
|
||||
title: inline_to_text(h),
|
||||
});
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
struct HeadingProj {
|
||||
level: u8,
|
||||
title: String,
|
||||
}
|
||||
|
||||
fn inline_to_text(h: &HeadingNode) -> String {
|
||||
let mut s = String::new();
|
||||
for n in &h.children {
|
||||
push_inline(n, &mut s);
|
||||
}
|
||||
s.trim().to_string()
|
||||
}
|
||||
|
||||
fn push_inline(n: &InlineNode, out: &mut String) {
|
||||
match n {
|
||||
InlineNode::Text(t) => out.push_str(&t.content),
|
||||
InlineNode::Bold(b) => b.children.iter().for_each(|c| push_inline(c, out)),
|
||||
InlineNode::Italic(i) => i.children.iter().for_each(|c| push_inline(c, out)),
|
||||
InlineNode::BoldItalic(bi) => bi.children.iter().for_each(|c| push_inline(c, out)),
|
||||
InlineNode::Strikethrough(s) => s.children.iter().for_each(|c| push_inline(c, out)),
|
||||
InlineNode::Code(c) => out.push_str(&c.content),
|
||||
InlineNode::Superscript(s) => s.children.iter().for_each(|c| push_inline(c, out)),
|
||||
InlineNode::Subscript(s) => s.children.iter().for_each(|c| push_inline(c, out)),
|
||||
InlineNode::Color(c) => c.children.iter().for_each(|c| push_inline(c, out)),
|
||||
InlineNode::WikiLink(w) => match &w.description {
|
||||
Some(d) => d.iter().for_each(|c| push_inline(c, out)),
|
||||
None => {
|
||||
if let Some(p) = &w.target.path {
|
||||
out.push_str(p);
|
||||
}
|
||||
}
|
||||
},
|
||||
InlineNode::ExternalLink(e) => match &e.description {
|
||||
Some(d) => d.iter().for_each(|c| push_inline(c, out)),
|
||||
None => out.push_str(&e.url),
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn escape_html(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for ch in s.chars() {
|
||||
match ch {
|
||||
'&' => out.push_str("&"),
|
||||
'<' => out.push_str("<"),
|
||||
'>' => out.push_str(">"),
|
||||
'"' => out.push_str("""),
|
||||
_ => out.push(ch),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Minimal glob match for `exclude_files`. Handles `*`, `**`, `?` and
|
||||
/// literal characters. No character classes — those aren't needed for
|
||||
/// "exclude foo/*.tmp" style patterns.
|
||||
fn glob_match(pattern: &str, text: &str) -> bool {
|
||||
glob_impl(pattern.as_bytes(), text.as_bytes())
|
||||
}
|
||||
|
||||
fn glob_impl(p: &[u8], t: &[u8]) -> bool {
|
||||
let mut pi = 0;
|
||||
let mut ti = 0;
|
||||
let mut star: Option<(usize, usize)> = None;
|
||||
let mut globstar = false;
|
||||
while ti < t.len() {
|
||||
if pi < p.len() && pi + 1 < p.len() && p[pi] == b'*' && p[pi + 1] == b'*' {
|
||||
globstar = true;
|
||||
star = Some((pi, ti));
|
||||
pi += 2;
|
||||
// Allow optional `/` after globstar.
|
||||
if pi < p.len() && p[pi] == b'/' {
|
||||
pi += 1;
|
||||
}
|
||||
continue;
|
||||
} else if pi < p.len() && p[pi] == b'*' {
|
||||
star = Some((pi, ti));
|
||||
pi += 1;
|
||||
globstar = false;
|
||||
continue;
|
||||
} else if pi < p.len() && (p[pi] == b'?' || p[pi] == t[ti]) {
|
||||
pi += 1;
|
||||
ti += 1;
|
||||
continue;
|
||||
} else if let Some((s_pi, s_ti)) = star {
|
||||
if !globstar && t[s_ti] == b'/' && t[ti] != b'/' {
|
||||
// single-star can't cross a `/`
|
||||
return false;
|
||||
}
|
||||
pi = if globstar { s_pi + 2 } else { s_pi + 1 };
|
||||
ti = s_ti + 1;
|
||||
star = Some((s_pi, ti));
|
||||
continue;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
while pi < p.len() && (p[pi] == b'*' || (p[pi] == b'/' && globstar)) {
|
||||
pi += 1;
|
||||
}
|
||||
pi == p.len()
|
||||
}
|
||||
|
||||
/// `<html_path>/<css_name>` — the absolute path of the CSS file the
|
||||
/// renderer references via `{{root_path}}{{css}}`.
|
||||
pub fn css_path(cfg: &HtmlConfig) -> PathBuf {
|
||||
cfg.html_path.join(&cfg.css_name)
|
||||
}
|
||||
|
||||
/// Minimal default CSS. Used by `nuwiki.export.*` commands when no CSS
|
||||
/// file exists yet at [`css_path`]. Deliberately tiny — users are
|
||||
/// expected to replace it with their own.
|
||||
pub const DEFAULT_CSS: &str =
|
||||
"body{font-family:sans-serif;max-width:46em;margin:2em auto;padding:0 1em;line-height:1.55}\
|
||||
h1,h2,h3,h4,h5,h6{font-family:serif;line-height:1.2}\
|
||||
pre{background:#f4f4f4;padding:.5em;overflow:auto}\
|
||||
code{background:#f4f4f4;padding:.1em .25em;border-radius:3px}\
|
||||
table{border-collapse:collapse}\
|
||||
table td,table th{border:1px solid #ccc;padding:.25em .5em}\
|
||||
ul.toc{padding-left:1.5em}\n";
|
||||
|
||||
/// Convenience wrapper exposed for the dispatcher: convert a wiki config
|
||||
/// to the absolute output dir.
|
||||
pub fn html_output_dir(cfg: &WikiConfig) -> &Path {
|
||||
&cfg.html.html_path
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
//! `textDocument/foldingRange` provider.
|
||||
//!
|
||||
//! Strategy: one fold per heading (line → line before the next heading
|
||||
//! of same-or-higher level) plus one fold per top-level list block.
|
||||
//! Folding is deliberately conservative — we never produce nested folds
|
||||
//! beyond what the AST already groups, since vimwiki users mostly want
|
||||
//! "collapse this section" behaviour rather than fine-grained code-fold
|
||||
//! semantics.
|
||||
|
||||
use nuwiki_core::ast::{BlockNode, DocumentNode};
|
||||
use tower_lsp::lsp_types::{FoldingRange, FoldingRangeKind};
|
||||
|
||||
/// Compute folding ranges for the whole document. `total_lines` is the
|
||||
/// number of lines in the source (`text.lines().count() as u32`) — used
|
||||
/// to extend the last heading's fold to end-of-document.
|
||||
pub fn folding_ranges(ast: &DocumentNode, total_lines: u32) -> Vec<FoldingRange> {
|
||||
let mut out = Vec::new();
|
||||
let last_line = total_lines.saturating_sub(1);
|
||||
|
||||
// Collect heading spans + levels in source order. We walk the
|
||||
// top-level children only — nested headings inside blockquotes are
|
||||
// rare and folding them adds complexity for little gain.
|
||||
let mut headings: Vec<(u32, u8)> = Vec::new();
|
||||
for block in &ast.children {
|
||||
if let BlockNode::Heading(h) = block {
|
||||
headings.push((h.span.start.line, h.level));
|
||||
}
|
||||
}
|
||||
// For each heading, end-line is one before the next heading whose
|
||||
// level is <= current level (or last_line otherwise).
|
||||
for (i, &(line, level)) in headings.iter().enumerate() {
|
||||
let end = headings[i + 1..]
|
||||
.iter()
|
||||
.find(|(_, l)| *l <= level)
|
||||
.map(|(next_line, _)| next_line.saturating_sub(1))
|
||||
.unwrap_or(last_line);
|
||||
if end > line {
|
||||
out.push(FoldingRange {
|
||||
start_line: line,
|
||||
end_line: end,
|
||||
start_character: None,
|
||||
end_character: None,
|
||||
kind: Some(FoldingRangeKind::Region),
|
||||
collapsed_text: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Fold each top-level list. Sublists fold implicitly because their
|
||||
// parent item's source span already covers them.
|
||||
for block in &ast.children {
|
||||
if let BlockNode::List(l) = block {
|
||||
let start = l.span.start.line;
|
||||
let end = l.span.end.line;
|
||||
if end > start {
|
||||
out.push(FoldingRange {
|
||||
start_line: start,
|
||||
end_line: end,
|
||||
start_character: None,
|
||||
end_character: None,
|
||||
kind: Some(FoldingRangeKind::Region),
|
||||
collapsed_text: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
/// Count lines in `text` (always ≥ 1 for non-empty input).
|
||||
pub fn line_count(text: &str) -> u32 {
|
||||
if text.is_empty() {
|
||||
0
|
||||
} else {
|
||||
let mut n = text.bytes().filter(|b| *b == b'\n').count() as u32;
|
||||
if !text.ends_with('\n') {
|
||||
n += 1;
|
||||
} else {
|
||||
// trailing newline still bounds a virtual line; LSP positions
|
||||
// index 0-based, so the file ends at line `n` (the empty one
|
||||
// after the final newline). We treat "lines" as the count
|
||||
// *including* that trailing empty line for fold-end clamping.
|
||||
n += 1;
|
||||
}
|
||||
n
|
||||
}
|
||||
}
|
||||
@@ -1,640 +0,0 @@
|
||||
//! Workspace index — the in-memory model of every `.wiki` page nuwiki
|
||||
//! has seen, used to power nav features.
|
||||
//!
|
||||
//! The initial scan runs as a background tokio task so
|
||||
//! the server stays responsive on startup. Per-document updates land here
|
||||
//! synchronously via `upsert` whenever the LSP backend re-parses on
|
||||
//! `didOpen` / `didChange`.
|
||||
//!
|
||||
//! The index keeps only the projections nav needs (headings + outgoing
|
||||
//! links) — the full AST already lives in the backend's `documents`
|
||||
//! `DashMap`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use nuwiki_core::ast::{
|
||||
BlockNode, BlockquoteNode, DocumentNode, InlineNode, LinkKind, LinkTarget, ListNode, Span,
|
||||
TableNode, TagScope,
|
||||
};
|
||||
use nuwiki_core::date::DiaryDate;
|
||||
use tower_lsp::lsp_types::Url;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IndexedPage {
|
||||
pub uri: Url,
|
||||
/// Page name relative to the workspace root, without the `.wiki`
|
||||
/// extension. Falls back to the URL's last path segment if no root.
|
||||
pub name: String,
|
||||
pub title: Option<String>,
|
||||
pub headings: Vec<HeadingInfo>,
|
||||
pub outgoing: Vec<OutgoingLink>,
|
||||
/// Every `TagNode` on the page. `tags_by_name` on the
|
||||
/// containing `WorkspaceIndex` is the reverse map.
|
||||
pub tags: Vec<TagInfo>,
|
||||
/// `Some(date)` when this page is a *daily* diary entry —
|
||||
/// stem parses as `YYYY-MM-DD`. Equivalent to
|
||||
/// `diary_period.and_then(|p| if let DiaryPeriod::Day(d) = p { Some(d) } else { None })`.
|
||||
/// Kept as its own field for back-compat with prev/next/list callers
|
||||
/// that pre-date the multi-frequency upgrade.
|
||||
pub diary_date: Option<DiaryDate>,
|
||||
/// Cluster 4 (vimwiki parity): `Some(period)` for any diary entry
|
||||
/// (daily, weekly, monthly, yearly). The stem must match the
|
||||
/// canonical format for one of the four frequencies.
|
||||
pub diary_period: Option<nuwiki_core::date::DiaryPeriod>,
|
||||
}
|
||||
|
||||
/// One tag occurrence on a page. `name` is the bare tag string (no
|
||||
/// surrounding colons). Multiple tags on the same source line each get
|
||||
/// their own `TagInfo` so anchor lookup can return a precise location.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TagInfo {
|
||||
pub name: String,
|
||||
pub scope: TagScope,
|
||||
pub span: Span,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HeadingInfo {
|
||||
pub title: String,
|
||||
pub level: u8,
|
||||
pub anchor: String,
|
||||
pub span: Span,
|
||||
}
|
||||
|
||||
impl IndexedPage {
|
||||
/// Find a heading whose anchor matches `request`. The on-disk wikilink
|
||||
/// syntax lets users write either the slug (`[[Page#some-heading]]`) or
|
||||
/// the raw heading text (`[[Page#Some Heading]]`). `slugify` is
|
||||
/// idempotent on valid slug input, so applying it to the request
|
||||
/// before comparison handles both forms in a single pass.
|
||||
pub fn find_heading_by_anchor(&self, request: &str) -> Option<&HeadingInfo> {
|
||||
let needle = slugify(request);
|
||||
self.headings.iter().find(|h| h.anchor == needle)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OutgoingLink {
|
||||
/// Resolved target page name (for `LinkKind::Wiki`) or `None` for kinds
|
||||
/// that don't address another page in the workspace (`AnchorOnly`,
|
||||
/// `Raw`, `File`, `Local`, `Diary`, …).
|
||||
pub target_page: Option<String>,
|
||||
pub anchor: Option<String>,
|
||||
pub kind: LinkKind,
|
||||
pub span: Span,
|
||||
/// Mirrors `LinkTarget::is_absolute` so `classify_outgoing` and the
|
||||
/// `links` query can distinguish `[[Page]]` (source-relative) from
|
||||
/// `[[/Page]]` (root-relative) without re-parsing.
|
||||
pub is_absolute: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Backlink {
|
||||
pub source_uri: Url,
|
||||
pub source_span: Span,
|
||||
pub target_anchor: Option<String>,
|
||||
}
|
||||
|
||||
/// A tag's location, used by `WorkspaceIndex::tags_for` for cross-page
|
||||
/// `[[Page#tag]]` resolution and `nuwiki.tags.search`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TagOccurrence {
|
||||
pub uri: Url,
|
||||
pub span: Span,
|
||||
pub scope: TagScope,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct WorkspaceIndex {
|
||||
pub root: Option<PathBuf>,
|
||||
/// Subdir relative to `root` that holds diary entries.
|
||||
/// Mirrors `WikiConfig::diary_rel_path`; stored here so `upsert` can
|
||||
/// classify each indexed URI without a back-reference to the config.
|
||||
pub diary_rel_path: Option<String>,
|
||||
/// File extension for this wiki's pages (e.g. `".wiki"` or `"wiki"`).
|
||||
/// Used to normalise link targets that include the extension so that
|
||||
/// `[[page.wiki]]` resolves the same way as `[[page]]`.
|
||||
pub file_extension: Option<String>,
|
||||
pub pages_by_uri: HashMap<Url, IndexedPage>,
|
||||
pub pages_by_name: HashMap<String, Url>,
|
||||
pub backlinks: HashMap<String, Vec<Backlink>>,
|
||||
/// Tag name → every occurrence across the workspace. Used by
|
||||
/// the nav handlers (tag-as-anchor `definition`, `workspace/symbol`
|
||||
/// inclusion) and the `nuwiki.tags.search` command.
|
||||
pub tags_by_name: HashMap<String, Vec<TagOccurrence>>,
|
||||
}
|
||||
|
||||
impl WorkspaceIndex {
|
||||
pub fn new(root: Option<PathBuf>) -> Self {
|
||||
Self {
|
||||
root,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_diary_rel_path(mut self, rel: Option<String>) -> Self {
|
||||
self.diary_rel_path = rel;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_file_extension(mut self, ext: Option<String>) -> Self {
|
||||
self.file_extension = ext;
|
||||
self
|
||||
}
|
||||
|
||||
/// Strip the wiki's configured file extension from a link target path.
|
||||
/// Handles both `"page.wiki"` → `"page"` and `"subdir/page.wiki"` →
|
||||
/// `"subdir/page"`. Returns `path` unchanged when the extension doesn't
|
||||
/// match or no extension is configured.
|
||||
pub fn strip_link_extension<'a>(&self, path: &'a str) -> &'a str {
|
||||
strip_wiki_extension(path, self.file_extension.as_deref())
|
||||
}
|
||||
|
||||
/// Insert or update an indexed page. Removes any prior indexing for
|
||||
/// the same URI first (handles renames and re-parses without leaking
|
||||
/// stale backlinks).
|
||||
pub fn upsert(&mut self, uri: Url, ast: &DocumentNode) {
|
||||
self.remove(&uri);
|
||||
let name = page_name_from_uri(&uri, self.root.as_deref());
|
||||
let diary_period =
|
||||
diary_period_for_uri(&uri, self.root.as_deref(), self.diary_rel_path.as_deref());
|
||||
let diary_date = diary_period.and_then(|p| match p {
|
||||
nuwiki_core::date::DiaryPeriod::Day(d) => Some(d),
|
||||
_ => None,
|
||||
});
|
||||
let mut page = IndexedPage {
|
||||
uri: uri.clone(),
|
||||
name: name.clone(),
|
||||
title: ast.metadata.title.clone(),
|
||||
headings: Vec::new(),
|
||||
outgoing: Vec::new(),
|
||||
tags: Vec::new(),
|
||||
diary_date,
|
||||
diary_period,
|
||||
};
|
||||
index_blocks(&ast.children, &mut page);
|
||||
|
||||
let ext = self.file_extension.as_deref();
|
||||
for link in &page.outgoing {
|
||||
if let Some(tgt) = &link.target_page {
|
||||
let key = canonical_link_name(tgt, &name, link.is_absolute, ext);
|
||||
self.backlinks.entry(key).or_default().push(Backlink {
|
||||
source_uri: uri.clone(),
|
||||
source_span: link.span,
|
||||
target_anchor: link.anchor.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Maintain the reverse tag map alongside the per-page list.
|
||||
for tag in &page.tags {
|
||||
self.tags_by_name
|
||||
.entry(tag.name.clone())
|
||||
.or_default()
|
||||
.push(TagOccurrence {
|
||||
uri: uri.clone(),
|
||||
span: tag.span,
|
||||
scope: tag.scope,
|
||||
});
|
||||
}
|
||||
|
||||
self.pages_by_name.insert(name, uri.clone());
|
||||
self.pages_by_uri.insert(uri, page);
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, uri: &Url) {
|
||||
if let Some(page) = self.pages_by_uri.remove(uri) {
|
||||
self.pages_by_name.remove(&page.name);
|
||||
}
|
||||
for entries in self.backlinks.values_mut() {
|
||||
entries.retain(|b| &b.source_uri != uri);
|
||||
}
|
||||
self.backlinks.retain(|_, v| !v.is_empty());
|
||||
for entries in self.tags_by_name.values_mut() {
|
||||
entries.retain(|t| &t.uri != uri);
|
||||
}
|
||||
self.tags_by_name.retain(|_, v| !v.is_empty());
|
||||
}
|
||||
|
||||
/// All occurrences of a tag across the workspace. Empty slice when the
|
||||
/// tag isn't indexed.
|
||||
pub fn tags_for(&self, name: &str) -> &[TagOccurrence] {
|
||||
self.tags_by_name
|
||||
.get(name)
|
||||
.map(Vec::as_slice)
|
||||
.unwrap_or(&[])
|
||||
}
|
||||
|
||||
/// Resolve a `LinkTarget` to a page URI in this workspace.
|
||||
/// `source_page` is the name of the page the link originates from —
|
||||
/// needed for `AnchorOnly` resolution and source-relative `Wiki` links.
|
||||
pub fn resolve(&self, target: &LinkTarget, source_page: &str) -> Option<&Url> {
|
||||
match target.kind {
|
||||
LinkKind::Wiki => target
|
||||
.path
|
||||
.as_deref()
|
||||
.and_then(|p| self.resolve_wiki_path(p, source_page, target.is_absolute)),
|
||||
LinkKind::AnchorOnly => self.pages_by_name.get(source_page),
|
||||
// Interwiki / Diary / File / Local / Raw aren't resolved
|
||||
// against the workspace index here. The handlers fall back to
|
||||
// best-effort resolution (e.g. building file:// URLs).
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Look up a wiki link target by name. Tries source-relative first
|
||||
/// (vimwiki's default — `[[Page]]` in `posts/index.wiki` opens
|
||||
/// `posts/Page.wiki`) then falls back to root-relative. Skips the
|
||||
/// source-relative attempt for explicitly absolute (`[[/Page]]`) and
|
||||
/// for source pages already at the wiki root (`source_page` with no
|
||||
/// `/`). Strips the configured `.wiki` extension and collapses any
|
||||
/// `.`/`..` segments before lookup.
|
||||
pub fn resolve_wiki_path(
|
||||
&self,
|
||||
path: &str,
|
||||
source_page: &str,
|
||||
is_absolute: bool,
|
||||
) -> Option<&Url> {
|
||||
let normalized = self.strip_link_extension(path);
|
||||
if !is_absolute {
|
||||
if let Some(slash) = source_page.rfind('/') {
|
||||
let candidate = collapse_dots(&format!("{}/{}", &source_page[..slash], normalized));
|
||||
if let Some(uri) = self.pages_by_name.get(&candidate) {
|
||||
return Some(uri);
|
||||
}
|
||||
}
|
||||
}
|
||||
let root_candidate = collapse_dots(normalized);
|
||||
self.pages_by_name.get(&root_candidate)
|
||||
}
|
||||
|
||||
pub fn page(&self, uri: &Url) -> Option<&IndexedPage> {
|
||||
self.pages_by_uri.get(uri)
|
||||
}
|
||||
|
||||
pub fn page_by_name(&self, name: &str) -> Option<&IndexedPage> {
|
||||
self.pages_by_name
|
||||
.get(name)
|
||||
.and_then(|u| self.pages_by_uri.get(u))
|
||||
}
|
||||
|
||||
/// Resolve a wiki link target to the page it points at. Source-relative
|
||||
/// first, then root-relative — mirrors [`Self::resolve_wiki_path`] but
|
||||
/// returns the `IndexedPage` so anchor checks have everything they need.
|
||||
pub fn page_for_wiki_target(
|
||||
&self,
|
||||
path: &str,
|
||||
source_page: &str,
|
||||
is_absolute: bool,
|
||||
) -> Option<&IndexedPage> {
|
||||
self.resolve_wiki_path(path, source_page, is_absolute)
|
||||
.and_then(|u| self.pages_by_uri.get(u))
|
||||
}
|
||||
|
||||
pub fn page_names(&self) -> Vec<String> {
|
||||
let mut v: Vec<String> = self.pages_by_name.keys().cloned().collect();
|
||||
v.sort();
|
||||
v
|
||||
}
|
||||
|
||||
pub fn backlinks_for(&self, page_name: &str) -> &[Backlink] {
|
||||
self.backlinks
|
||||
.get(page_name)
|
||||
.map(Vec::as_slice)
|
||||
.unwrap_or(&[])
|
||||
}
|
||||
}
|
||||
|
||||
/// Return a parsed [`DiaryDate`] when `uri` is a daily diary entry —
|
||||
/// the path lives under `<root>/<diary_rel_path>/` and its stem parses
|
||||
/// as `YYYY-MM-DD`. For weekly/monthly/yearly entries see
|
||||
/// [`diary_period_for_uri`].
|
||||
pub fn diary_date_for_uri(
|
||||
uri: &Url,
|
||||
root: Option<&Path>,
|
||||
diary_rel_path: Option<&str>,
|
||||
) -> Option<DiaryDate> {
|
||||
match diary_period_for_uri(uri, root, diary_rel_path)? {
|
||||
nuwiki_core::date::DiaryPeriod::Day(d) => Some(d),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Return a parsed [`DiaryPeriod`] when `uri` is a diary entry at any of
|
||||
/// the four supported frequencies. Recognises:
|
||||
///
|
||||
/// `YYYY-MM-DD` → Day
|
||||
/// `YYYY-Www` → Week (ISO)
|
||||
/// `YYYY-MM` → Month
|
||||
/// `YYYY` → Year
|
||||
///
|
||||
/// As with `diary_date_for_uri`, the path must sit under
|
||||
/// `<root>/<diary_rel_path>/` to be accepted; without a `root` we accept
|
||||
/// any stem (so ad-hoc test setups still work).
|
||||
pub fn diary_period_for_uri(
|
||||
uri: &Url,
|
||||
root: Option<&Path>,
|
||||
diary_rel_path: Option<&str>,
|
||||
) -> Option<nuwiki_core::date::DiaryPeriod> {
|
||||
let path = uri.to_file_path().ok()?;
|
||||
let stem = path.file_stem()?.to_str()?;
|
||||
let parsed = nuwiki_core::date::DiaryPeriod::parse(stem)?;
|
||||
let Some(root) = root else {
|
||||
return Some(parsed);
|
||||
};
|
||||
let parent = path.parent()?;
|
||||
let expected = root.join(diary_rel_path.unwrap_or("diary"));
|
||||
if parent.starts_with(&expected) {
|
||||
Some(parsed)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive a page name from a `file://` URL. With a workspace root the name
|
||||
/// is the URL's path relative to root, sans `.wiki`. Without a root it's
|
||||
/// just the filename stem.
|
||||
pub fn page_name_from_uri(uri: &Url, root: Option<&Path>) -> String {
|
||||
if let (Some(root), Ok(p)) = (root, uri.to_file_path()) {
|
||||
if let Ok(rel) = p.strip_prefix(root) {
|
||||
let stripped = rel.with_extension("");
|
||||
return stripped
|
||||
.to_string_lossy()
|
||||
.replace(std::path::MAIN_SEPARATOR, "/");
|
||||
}
|
||||
}
|
||||
// Fallback: last path segment, strip .wiki if present.
|
||||
if let Ok(p) = uri.to_file_path() {
|
||||
return p
|
||||
.file_stem()
|
||||
.map(|s| s.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
}
|
||||
uri.path_segments()
|
||||
.and_then(|mut s| s.next_back())
|
||||
.map(|s| {
|
||||
s.strip_suffix(".wiki")
|
||||
.unwrap_or(s)
|
||||
.trim_start_matches('/')
|
||||
.to_string()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Normalise heading text into a URL-safe anchor slug.
|
||||
pub fn slugify(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut prev_dash = false;
|
||||
for ch in s.chars() {
|
||||
if ch.is_alphanumeric() {
|
||||
for c in ch.to_lowercase() {
|
||||
out.push(c);
|
||||
}
|
||||
prev_dash = false;
|
||||
} else if (ch.is_whitespace() || ch == '-' || ch == '_') && !prev_dash && !out.is_empty() {
|
||||
out.push('-');
|
||||
prev_dash = true;
|
||||
}
|
||||
// other punctuation: drop
|
||||
}
|
||||
while out.ends_with('-') {
|
||||
out.pop();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// ===== Indexing helpers =====
|
||||
|
||||
fn index_blocks(blocks: &[BlockNode], page: &mut IndexedPage) {
|
||||
for block in blocks {
|
||||
index_block(block, page);
|
||||
}
|
||||
}
|
||||
|
||||
fn index_block(block: &BlockNode, page: &mut IndexedPage) {
|
||||
match block {
|
||||
BlockNode::Heading(h) => {
|
||||
let title = inline_to_text(&h.children);
|
||||
page.headings.push(HeadingInfo {
|
||||
title: title.clone(),
|
||||
level: h.level,
|
||||
anchor: slugify(&title),
|
||||
span: h.span,
|
||||
});
|
||||
index_inlines(&h.children, page);
|
||||
}
|
||||
BlockNode::Paragraph(p) => index_inlines(&p.children, page),
|
||||
BlockNode::Blockquote(BlockquoteNode { children, .. }) => {
|
||||
for c in children {
|
||||
index_block(c, page);
|
||||
}
|
||||
}
|
||||
BlockNode::List(ListNode { items, .. }) => {
|
||||
for item in items {
|
||||
index_inlines(&item.children, page);
|
||||
if let Some(sub) = &item.sublist {
|
||||
index_block(&BlockNode::List(sub.clone()), page);
|
||||
}
|
||||
}
|
||||
}
|
||||
BlockNode::DefinitionList(dl) => {
|
||||
for item in &dl.items {
|
||||
if let Some(term) = &item.term {
|
||||
index_inlines(term, page);
|
||||
}
|
||||
for def in &item.definitions {
|
||||
index_inlines(def, page);
|
||||
}
|
||||
}
|
||||
}
|
||||
BlockNode::Table(TableNode { rows, .. }) => {
|
||||
for row in rows {
|
||||
for cell in &row.cells {
|
||||
index_inlines(&cell.children, page);
|
||||
}
|
||||
}
|
||||
}
|
||||
BlockNode::Tag(t) => {
|
||||
for name in &t.tags {
|
||||
page.tags.push(TagInfo {
|
||||
name: name.clone(),
|
||||
scope: t.scope,
|
||||
span: t.span,
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn index_inlines(inlines: &[InlineNode], page: &mut IndexedPage) {
|
||||
for n in inlines {
|
||||
match n {
|
||||
InlineNode::WikiLink(w) => {
|
||||
page.outgoing.push(OutgoingLink {
|
||||
target_page: w.target.path.clone(),
|
||||
anchor: w.target.anchor.clone(),
|
||||
kind: w.target.kind,
|
||||
span: w.span,
|
||||
is_absolute: w.target.is_absolute,
|
||||
});
|
||||
if let Some(desc) = &w.description {
|
||||
index_inlines(desc, page);
|
||||
}
|
||||
}
|
||||
InlineNode::ExternalLink(e) => {
|
||||
if let Some(desc) = &e.description {
|
||||
index_inlines(desc, page);
|
||||
}
|
||||
}
|
||||
InlineNode::Bold(b) => index_inlines(&b.children, page),
|
||||
InlineNode::Italic(i) => index_inlines(&i.children, page),
|
||||
InlineNode::BoldItalic(bi) => index_inlines(&bi.children, page),
|
||||
InlineNode::Strikethrough(s) => index_inlines(&s.children, page),
|
||||
InlineNode::Superscript(s) => index_inlines(&s.children, page),
|
||||
InlineNode::Subscript(s) => index_inlines(&s.children, page),
|
||||
InlineNode::Color(c) => index_inlines(&c.children, page),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn inline_to_text(inlines: &[InlineNode]) -> String {
|
||||
let mut out = String::new();
|
||||
inline_to_text_into(inlines, &mut out);
|
||||
out.trim().to_string()
|
||||
}
|
||||
|
||||
fn inline_to_text_into(inlines: &[InlineNode], out: &mut String) {
|
||||
for n in inlines {
|
||||
match n {
|
||||
InlineNode::Text(t) => out.push_str(&t.content),
|
||||
InlineNode::Bold(b) => inline_to_text_into(&b.children, out),
|
||||
InlineNode::Italic(i) => inline_to_text_into(&i.children, out),
|
||||
InlineNode::BoldItalic(bi) => inline_to_text_into(&bi.children, out),
|
||||
InlineNode::Strikethrough(s) => inline_to_text_into(&s.children, out),
|
||||
InlineNode::Code(c) => out.push_str(&c.content),
|
||||
InlineNode::Superscript(s) => inline_to_text_into(&s.children, out),
|
||||
InlineNode::Subscript(s) => inline_to_text_into(&s.children, out),
|
||||
InlineNode::MathInline(m) => out.push_str(&m.content),
|
||||
InlineNode::Color(c) => inline_to_text_into(&c.children, out),
|
||||
InlineNode::WikiLink(w) => {
|
||||
if let Some(d) = &w.description {
|
||||
inline_to_text_into(d, out);
|
||||
} else if let Some(p) = &w.target.path {
|
||||
out.push_str(p);
|
||||
}
|
||||
}
|
||||
InlineNode::ExternalLink(e) => match &e.description {
|
||||
Some(d) => inline_to_text_into(d, out),
|
||||
None => out.push_str(&e.url),
|
||||
},
|
||||
InlineNode::Keyword(_) => {}
|
||||
InlineNode::Transclusion(t) => out.push_str(t.alt.as_deref().unwrap_or(&t.url)),
|
||||
InlineNode::RawUrl(r) => out.push_str(&r.url),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Filesystem walking =====
|
||||
|
||||
/// Canonicalise a wikilink target into the page name the workspace index
|
||||
/// would key it by. Mirrors [`WorkspaceIndex::resolve_wiki_path`]'s preference
|
||||
/// (source-relative when not explicitly absolute, root-relative otherwise) but
|
||||
/// without requiring the target to already be indexed — useful for backlinks
|
||||
/// where the target may be added to the index after the source.
|
||||
///
|
||||
/// Collapses `.` and `..` segments so `posts/../foo` → `foo`. `..` at the
|
||||
/// root of the wiki is treated as a no-op (the wiki has no parent in this
|
||||
/// model — interwiki crossings use the dedicated `[[wiki:Page]]` syntax).
|
||||
pub fn canonical_link_name(
|
||||
path: &str,
|
||||
source_page: &str,
|
||||
is_absolute: bool,
|
||||
file_extension: Option<&str>,
|
||||
) -> String {
|
||||
let normalized = strip_wiki_extension(path, file_extension);
|
||||
let joined = if !is_absolute {
|
||||
if let Some(slash) = source_page.rfind('/') {
|
||||
format!("{}/{}", &source_page[..slash], normalized)
|
||||
} else {
|
||||
normalized.to_string()
|
||||
}
|
||||
} else {
|
||||
normalized.to_string()
|
||||
};
|
||||
collapse_dots(&joined)
|
||||
}
|
||||
|
||||
/// Collapse `.` and `..` segments in a `/`-separated path string. Doesn't
|
||||
/// touch URI escapes — these are workspace-relative page names, not URLs.
|
||||
pub fn collapse_dots(s: &str) -> String {
|
||||
let mut parts: Vec<&str> = Vec::new();
|
||||
for seg in s.split('/') {
|
||||
match seg {
|
||||
"" | "." => continue,
|
||||
".." => {
|
||||
parts.pop();
|
||||
}
|
||||
_ => parts.push(seg),
|
||||
}
|
||||
}
|
||||
parts.join("/")
|
||||
}
|
||||
|
||||
/// Strip a wiki file extension from a link target path without allocating.
|
||||
///
|
||||
/// `file_extension` may be `".wiki"` (with leading dot) or `"wiki"` (without).
|
||||
/// Returns `path` unchanged when no extension is configured, the extension is
|
||||
/// empty, or the path doesn't end with `.{ext}`.
|
||||
pub fn strip_wiki_extension<'a>(path: &'a str, file_extension: Option<&str>) -> &'a str {
|
||||
let ext = match file_extension {
|
||||
Some(e) => e.trim_start_matches('.'),
|
||||
None => return path,
|
||||
};
|
||||
if ext.is_empty() {
|
||||
return path;
|
||||
}
|
||||
let dot_ext_len = ext.len() + 1; // +1 for the '.'
|
||||
if path.len() > dot_ext_len
|
||||
&& path.ends_with(ext)
|
||||
&& path.as_bytes()[path.len() - dot_ext_len] == b'.'
|
||||
{
|
||||
&path[..path.len() - dot_ext_len]
|
||||
} else {
|
||||
path
|
||||
}
|
||||
}
|
||||
|
||||
/// Recursively collect every `.wiki` file under `root`, skipping dotfiles
|
||||
/// and dot-directories.
|
||||
pub fn walk_wiki_files(root: &Path) -> Vec<PathBuf> {
|
||||
let mut out = Vec::new();
|
||||
let mut stack = vec![root.to_path_buf()];
|
||||
while let Some(dir) = stack.pop() {
|
||||
let entries = match std::fs::read_dir(&dir) {
|
||||
Ok(e) => e,
|
||||
Err(_) => continue,
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
let Some(name) = path.file_name() else {
|
||||
continue;
|
||||
};
|
||||
if name.to_string_lossy().starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
let Ok(meta) = entry.metadata() else {
|
||||
continue;
|
||||
};
|
||||
if meta.is_dir() {
|
||||
stack.push(path);
|
||||
} else if meta.is_file() && path.extension().and_then(|e| e.to_str()) == Some("wiki") {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort();
|
||||
out
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,149 +0,0 @@
|
||||
//! Helpers shared by the navigation handlers (definition,
|
||||
//! references, hover, completion).
|
||||
//!
|
||||
//! Two responsibilities:
|
||||
//!
|
||||
//! 1. **Position conversion** — clients send LSP `Position`s in either
|
||||
//! UTF-8 or UTF-16 encoding; nuwiki AST spans are byte offsets.
|
||||
//! `lsp_to_byte_pos` translates the LSP position into the same shape
|
||||
//! so it can be compared against AST spans.
|
||||
//! 2. **Position lookup** — given a byte position, find the most specific
|
||||
//! `InlineNode` whose span covers it. Used to identify the link under
|
||||
//! the cursor.
|
||||
|
||||
use nuwiki_core::ast::{BlockNode, DocumentNode, InlineNode, ListItemNode, Span};
|
||||
use tower_lsp::lsp_types::Position as LspPosition;
|
||||
|
||||
use crate::semantic_tokens::LineIndex;
|
||||
|
||||
/// Translate an LSP `Position` (whose `character` is a UTF-16 code-unit
|
||||
/// offset by default, or a UTF-8 byte offset when negotiated) into our
|
||||
/// internal `(line, byte_col)` shape.
|
||||
pub fn lsp_to_byte_pos(p: LspPosition, text: &str, utf8: bool) -> (u32, u32) {
|
||||
if utf8 {
|
||||
return (p.line, p.character);
|
||||
}
|
||||
let idx = LineIndex::new(text);
|
||||
let line_str = idx.line_str(p.line, text);
|
||||
let byte_col = utf16_to_byte_col(line_str, p.character);
|
||||
(p.line, byte_col)
|
||||
}
|
||||
|
||||
fn utf16_to_byte_col(line: &str, target_utf16: u32) -> u32 {
|
||||
let mut byte = 0u32;
|
||||
let mut units = 0u32;
|
||||
for ch in line.chars() {
|
||||
if units >= target_utf16 {
|
||||
break;
|
||||
}
|
||||
units += ch.len_utf16() as u32;
|
||||
byte += ch.len_utf8() as u32;
|
||||
}
|
||||
byte
|
||||
}
|
||||
|
||||
/// Find the most specific inline node whose span covers the given byte
|
||||
/// position. Walks block structure first, then descends into inline
|
||||
/// containers (bold, italic, …) until no child claims the position.
|
||||
pub fn find_inline_at(doc: &DocumentNode, line: u32, byte_col: u32) -> Option<&InlineNode> {
|
||||
for block in &doc.children {
|
||||
if let Some(node) = find_in_block(block, line, byte_col) {
|
||||
return Some(node);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn find_in_block(block: &BlockNode, line: u32, col: u32) -> Option<&InlineNode> {
|
||||
match block {
|
||||
BlockNode::Heading(h) => find_in_inlines(&h.children, line, col),
|
||||
BlockNode::Paragraph(p) => find_in_inlines(&p.children, line, col),
|
||||
BlockNode::Blockquote(b) => b.children.iter().find_map(|c| find_in_block(c, line, col)),
|
||||
BlockNode::List(l) => l
|
||||
.items
|
||||
.iter()
|
||||
.find_map(|it| find_in_list_item(it, line, col)),
|
||||
BlockNode::DefinitionList(dl) => dl.items.iter().find_map(|it| {
|
||||
it.term
|
||||
.as_ref()
|
||||
.and_then(|t| find_in_inlines(t, line, col))
|
||||
.or_else(|| {
|
||||
it.definitions
|
||||
.iter()
|
||||
.find_map(|d| find_in_inlines(d, line, col))
|
||||
})
|
||||
}),
|
||||
BlockNode::Table(t) => t.rows.iter().find_map(|r| {
|
||||
r.cells
|
||||
.iter()
|
||||
.find_map(|c| find_in_inlines(&c.children, line, col))
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn find_in_list_item(item: &ListItemNode, line: u32, col: u32) -> Option<&InlineNode> {
|
||||
find_in_inlines(&item.children, line, col).or_else(|| {
|
||||
item.sublist.as_ref().and_then(|sub| {
|
||||
sub.items
|
||||
.iter()
|
||||
.find_map(|i| find_in_list_item(i, line, col))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn find_in_inlines(inlines: &[InlineNode], line: u32, col: u32) -> Option<&InlineNode> {
|
||||
for n in inlines {
|
||||
if !span_contains(span_of_inline(n), line, col) {
|
||||
continue;
|
||||
}
|
||||
// Descend into inline containers so we return the *innermost* hit.
|
||||
let deeper = match n {
|
||||
InlineNode::Bold(b) => find_in_inlines(&b.children, line, col),
|
||||
InlineNode::Italic(i) => find_in_inlines(&i.children, line, col),
|
||||
InlineNode::BoldItalic(bi) => find_in_inlines(&bi.children, line, col),
|
||||
InlineNode::Strikethrough(s) => find_in_inlines(&s.children, line, col),
|
||||
InlineNode::Superscript(s) => find_in_inlines(&s.children, line, col),
|
||||
InlineNode::Subscript(s) => find_in_inlines(&s.children, line, col),
|
||||
InlineNode::Color(c) => find_in_inlines(&c.children, line, col),
|
||||
InlineNode::WikiLink(w) => w
|
||||
.description
|
||||
.as_ref()
|
||||
.and_then(|d| find_in_inlines(d, line, col)),
|
||||
InlineNode::ExternalLink(e) => e
|
||||
.description
|
||||
.as_ref()
|
||||
.and_then(|d| find_in_inlines(d, line, col)),
|
||||
_ => None,
|
||||
};
|
||||
return Some(deeper.unwrap_or(n));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn span_contains(s: Span, line: u32, col: u32) -> bool {
|
||||
let start = (s.start.line, s.start.column);
|
||||
let end = (s.end.line, s.end.column);
|
||||
let p = (line, col);
|
||||
p >= start && p < end
|
||||
}
|
||||
|
||||
pub fn span_of_inline(node: &InlineNode) -> Span {
|
||||
match node {
|
||||
InlineNode::Text(n) => n.span,
|
||||
InlineNode::Bold(n) => n.span,
|
||||
InlineNode::Italic(n) => n.span,
|
||||
InlineNode::BoldItalic(n) => n.span,
|
||||
InlineNode::Strikethrough(n) => n.span,
|
||||
InlineNode::Code(n) => n.span,
|
||||
InlineNode::Superscript(n) => n.span,
|
||||
InlineNode::Subscript(n) => n.span,
|
||||
InlineNode::MathInline(n) => n.span,
|
||||
InlineNode::Keyword(n) => n.span,
|
||||
InlineNode::Color(n) => n.span,
|
||||
InlineNode::WikiLink(n) => n.span,
|
||||
InlineNode::ExternalLink(n) => n.span,
|
||||
InlineNode::Transclusion(n) => n.span,
|
||||
InlineNode::RawUrl(n) => n.span,
|
||||
}
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
//! `textDocument/rename` — server-side rename with cross-document link
|
||||
//! rewriting.
|
||||
//!
|
||||
//! Implementation:
|
||||
//! 1. Resolve the rename target — cursor on a wikilink renames the linked
|
||||
//! page; otherwise rename the current file.
|
||||
//! 2. Build the new URI from the wiki root + the user-supplied path.
|
||||
//! 3. For every page in the wiki's index with an outgoing link to the
|
||||
//! old page name, read its source via the closed-doc loader
|
||||
//! so cached spans are validated against current text, then emit a
|
||||
//! `TextEdit` that swaps the `[[old]]` for `[[new]]` while preserving
|
||||
//! anchors and descriptions.
|
||||
//! 4. Add the `RenameFile` op last so the builder emits it before content
|
||||
//! edits in `documentChanges` ordering.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use tower_lsp::lsp_types::{Url, WorkspaceEdit};
|
||||
|
||||
use crate::edits::{op_rename, text_edit_replace, WorkspaceEditBuilder};
|
||||
use crate::index::page_name_from_uri;
|
||||
use crate::Backend;
|
||||
|
||||
/// Compute the `WorkspaceEdit` for renaming `target_uri` to `new_name`.
|
||||
///
|
||||
/// Returns `None` when:
|
||||
/// - the URI doesn't belong to any registered wiki,
|
||||
/// - the new URI couldn't be built from the wiki root + new_name,
|
||||
/// - the new URI is the same as the old one.
|
||||
pub(crate) async fn compute_rename(
|
||||
backend: &Backend,
|
||||
target_uri: Url,
|
||||
new_name: String,
|
||||
utf8: bool,
|
||||
) -> Option<WorkspaceEdit> {
|
||||
let wiki = backend.wiki_for_uri(&target_uri)?;
|
||||
let old_name = page_name_from_uri(&target_uri, Some(&wiki.config.root));
|
||||
// Spaces in the link *target* (and the file on disk) are replaced with
|
||||
// the wiki's `links_space_char`; descriptions keep their original text.
|
||||
let new_name = apply_links_space_char(&new_name, &wiki.config.links_space_char);
|
||||
let new_uri = build_new_uri(&wiki.config.root, &new_name, &wiki.config.file_extension)?;
|
||||
if new_uri == target_uri {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut builder = WorkspaceEditBuilder::new();
|
||||
builder.file_op(op_rename(target_uri.clone(), new_uri));
|
||||
|
||||
// Snapshot the backlinks so the index read lock is released before we
|
||||
// await any disk I/O.
|
||||
let backlinks = {
|
||||
let idx = wiki.index.read().ok()?;
|
||||
idx.backlinks_for(&old_name).to_vec()
|
||||
};
|
||||
|
||||
for bl in backlinks {
|
||||
let Ok(snapshot) = backend.read_or_open(&bl.source_uri).await else {
|
||||
continue;
|
||||
};
|
||||
let start = bl.source_span.start.offset.min(snapshot.text.len());
|
||||
let end = bl.source_span.end.offset.min(snapshot.text.len());
|
||||
if start >= end {
|
||||
continue;
|
||||
}
|
||||
let original = &snapshot.text[start..end];
|
||||
if let Some(new_link) = rewrite_wikilink_target(original, &new_name) {
|
||||
let edit = text_edit_replace(bl.source_span, new_link, &snapshot.text, utf8);
|
||||
builder.edit(bl.source_uri.clone(), edit);
|
||||
}
|
||||
}
|
||||
|
||||
if builder.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(builder.build())
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace spaces in a link path with the configured `links_space_char`.
|
||||
/// The default `" "` is an identity transform (spaces kept verbatim).
|
||||
pub fn apply_links_space_char(name: &str, space_char: &str) -> String {
|
||||
if space_char == " " {
|
||||
name.to_string()
|
||||
} else {
|
||||
name.replace(' ', space_char)
|
||||
}
|
||||
}
|
||||
|
||||
/// Build `<root>/<new_name>(<ext>)` as a `file://` URL.
|
||||
pub fn build_new_uri(root: &Path, new_name: &str, ext: &str) -> Option<Url> {
|
||||
let mut path = root.to_path_buf();
|
||||
for part in new_name.split(['/', '\\']) {
|
||||
if !part.is_empty() {
|
||||
path.push(part);
|
||||
}
|
||||
}
|
||||
let path_str = path.to_string_lossy().to_string();
|
||||
let with_ext = if path_str.ends_with(ext) {
|
||||
path_str
|
||||
} else {
|
||||
format!("{path_str}{ext}")
|
||||
};
|
||||
Url::from_file_path(with_ext).ok()
|
||||
}
|
||||
|
||||
/// Rewrite a `[[...]]` source segment so the path portion becomes
|
||||
/// `new_path`, keeping any `#anchor` and `|description` parts intact.
|
||||
///
|
||||
/// Returns `None` if `segment` doesn't look like a wikilink (starts with
|
||||
/// `[[`, ends with `]]`).
|
||||
pub fn rewrite_wikilink_target(segment: &str, new_path: &str) -> Option<String> {
|
||||
if !segment.starts_with("[[") || !segment.ends_with("]]") || segment.len() < 4 {
|
||||
return None;
|
||||
}
|
||||
let inner = &segment[2..segment.len() - 2];
|
||||
let (path_part, description) = match inner.find('|') {
|
||||
Some(i) => (&inner[..i], Some(&inner[i + 1..])),
|
||||
None => (inner, None),
|
||||
};
|
||||
let anchor = path_part.find('#').map(|i| &path_part[i + 1..]);
|
||||
|
||||
let mut out = String::with_capacity(segment.len());
|
||||
out.push_str("[[");
|
||||
out.push_str(new_path);
|
||||
if let Some(a) = anchor {
|
||||
out.push('#');
|
||||
out.push_str(a);
|
||||
}
|
||||
if let Some(d) = description {
|
||||
out.push('|');
|
||||
out.push_str(d);
|
||||
}
|
||||
out.push_str("]]");
|
||||
Some(out)
|
||||
}
|
||||
@@ -1,389 +0,0 @@
|
||||
//! Semantic-token emission for the vimwiki AST.
|
||||
//!
|
||||
//! Token-type scheme: custom `vimwiki*` types plus
|
||||
//! `level1`..`level6` + `centered` modifiers.
|
||||
//!
|
||||
//! Pipeline:
|
||||
//!
|
||||
//! 1. Walk the AST, emitting one or more `RawToken`s per AST node. Block
|
||||
//! structure that "wraps" text (paragraph, list item, table cell)
|
||||
//! recurses into inlines instead of emitting a wrapping token, so we
|
||||
//! end up with a non-overlapping tile of inline tokens. Block nodes
|
||||
//! that are themselves visually distinct (heading, code block, math
|
||||
//! block, comment) emit one token across their whole span.
|
||||
//! 2. Multi-line spans are split into one token per line — the LSP wire
|
||||
//! format can't express a single token that crosses a newline.
|
||||
//! 3. Tokens are sorted by `(line, byte_col)` and encoded as the LSP
|
||||
//! delta-quintuple stream `(deltaLine, deltaStart, length, type, mods)`.
|
||||
//! When the client doesn't support UTF-8 encoding, byte offsets are
|
||||
//! converted to UTF-16 code-unit counts on the fly.
|
||||
|
||||
use nuwiki_core::ast::{
|
||||
BlockNode, BlockquoteNode, DocumentNode, HeadingNode, InlineNode, ListItemNode, ListNode, Span,
|
||||
TableNode,
|
||||
};
|
||||
use tower_lsp::lsp_types::{Range, SemanticTokenModifier, SemanticTokenType, SemanticTokensLegend};
|
||||
|
||||
// ===== Legend =====
|
||||
|
||||
pub const TOKEN_HEADING: u32 = 0;
|
||||
pub const TOKEN_BOLD: u32 = 1;
|
||||
pub const TOKEN_ITALIC: u32 = 2;
|
||||
pub const TOKEN_BOLD_ITALIC: u32 = 3;
|
||||
pub const TOKEN_STRIKETHROUGH: u32 = 4;
|
||||
pub const TOKEN_SUPERSCRIPT: u32 = 5;
|
||||
pub const TOKEN_SUBSCRIPT: u32 = 6;
|
||||
pub const TOKEN_CODE: u32 = 7;
|
||||
pub const TOKEN_CODE_BLOCK: u32 = 8;
|
||||
pub const TOKEN_MATH: u32 = 9;
|
||||
pub const TOKEN_MATH_BLOCK: u32 = 10;
|
||||
pub const TOKEN_KEYWORD: u32 = 11;
|
||||
pub const TOKEN_COLOR: u32 = 12;
|
||||
pub const TOKEN_WIKILINK: u32 = 13;
|
||||
pub const TOKEN_EXTERNAL_LINK: u32 = 14;
|
||||
pub const TOKEN_TRANSCLUSION: u32 = 15;
|
||||
pub const TOKEN_URL: u32 = 16;
|
||||
pub const TOKEN_COMMENT: u32 = 17;
|
||||
pub const TOKEN_DEFINITION_TERM: u32 = 18;
|
||||
pub const TOKEN_TAG: u32 = 19;
|
||||
|
||||
pub const TOKEN_TYPE_NAMES: &[&str] = &[
|
||||
"vimwikiHeading",
|
||||
"vimwikiBold",
|
||||
"vimwikiItalic",
|
||||
"vimwikiBoldItalic",
|
||||
"vimwikiStrikethrough",
|
||||
"vimwikiSuperscript",
|
||||
"vimwikiSubscript",
|
||||
"vimwikiCode",
|
||||
"vimwikiCodeBlock",
|
||||
"vimwikiMath",
|
||||
"vimwikiMathBlock",
|
||||
"vimwikiKeyword",
|
||||
"vimwikiColor",
|
||||
"vimwikiWikilink",
|
||||
"vimwikiExternalLink",
|
||||
"vimwikiTransclusion",
|
||||
"vimwikiUrl",
|
||||
"vimwikiComment",
|
||||
"vimwikiDefinitionTerm",
|
||||
"vimwikiTag",
|
||||
];
|
||||
|
||||
pub const MOD_LEVEL1: u32 = 1 << 0;
|
||||
pub const MOD_LEVEL2: u32 = 1 << 1;
|
||||
pub const MOD_LEVEL3: u32 = 1 << 2;
|
||||
pub const MOD_LEVEL4: u32 = 1 << 3;
|
||||
pub const MOD_LEVEL5: u32 = 1 << 4;
|
||||
pub const MOD_LEVEL6: u32 = 1 << 5;
|
||||
pub const MOD_CENTERED: u32 = 1 << 6;
|
||||
|
||||
pub const TOKEN_MODIFIER_NAMES: &[&str] = &[
|
||||
"level1", "level2", "level3", "level4", "level5", "level6", "centered",
|
||||
];
|
||||
|
||||
pub fn legend() -> SemanticTokensLegend {
|
||||
SemanticTokensLegend {
|
||||
token_types: TOKEN_TYPE_NAMES
|
||||
.iter()
|
||||
.map(|s| SemanticTokenType::new(s))
|
||||
.collect(),
|
||||
token_modifiers: TOKEN_MODIFIER_NAMES
|
||||
.iter()
|
||||
.map(|s| SemanticTokenModifier::new(s))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Raw tokens =====
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct RawToken {
|
||||
pub line: u32,
|
||||
pub byte_col: u32,
|
||||
pub byte_len: u32,
|
||||
pub kind: u32,
|
||||
pub mods: u32,
|
||||
}
|
||||
|
||||
/// Index of byte offsets at the start of each line. Built once per request
|
||||
/// so multi-line span splitting and UTF-16 conversion stay linear in
|
||||
/// document size.
|
||||
pub struct LineIndex {
|
||||
pub starts: Vec<usize>,
|
||||
pub total: usize,
|
||||
}
|
||||
|
||||
impl LineIndex {
|
||||
pub fn new(text: &str) -> Self {
|
||||
let mut starts = vec![0];
|
||||
for (i, b) in text.bytes().enumerate() {
|
||||
if b == b'\n' {
|
||||
starts.push(i + 1);
|
||||
}
|
||||
}
|
||||
Self {
|
||||
starts,
|
||||
total: text.len(),
|
||||
}
|
||||
}
|
||||
|
||||
fn line_byte_range(&self, line: u32) -> (usize, usize) {
|
||||
let start = self
|
||||
.starts
|
||||
.get(line as usize)
|
||||
.copied()
|
||||
.unwrap_or(self.total);
|
||||
let end = self
|
||||
.starts
|
||||
.get(line as usize + 1)
|
||||
.map(|&s| s.saturating_sub(1))
|
||||
.unwrap_or(self.total);
|
||||
(start, end)
|
||||
}
|
||||
|
||||
pub fn line_str<'a>(&self, line: u32, text: &'a str) -> &'a str {
|
||||
let (s, e) = self.line_byte_range(line);
|
||||
&text[s..e]
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Collection =====
|
||||
|
||||
pub fn collect_tokens(doc: &DocumentNode, text: &str) -> Vec<RawToken> {
|
||||
let idx = LineIndex::new(text);
|
||||
let mut out = Vec::new();
|
||||
for block in &doc.children {
|
||||
emit_block(block, text, &idx, &mut out);
|
||||
}
|
||||
out.sort_by_key(|t| (t.line, t.byte_col));
|
||||
out
|
||||
}
|
||||
|
||||
fn emit_block(block: &BlockNode, text: &str, idx: &LineIndex, out: &mut Vec<RawToken>) {
|
||||
match block {
|
||||
BlockNode::Heading(h) => emit_heading(h, text, idx, out),
|
||||
BlockNode::Paragraph(p) => emit_inlines(&p.children, text, idx, out),
|
||||
BlockNode::HorizontalRule(_) => {}
|
||||
BlockNode::Blockquote(BlockquoteNode { children, .. }) => {
|
||||
for child in children {
|
||||
emit_block(child, text, idx, out);
|
||||
}
|
||||
}
|
||||
BlockNode::Preformatted(p) => split_span(p.span, text, idx, TOKEN_CODE_BLOCK, 0, out),
|
||||
BlockNode::MathBlock(m) => split_span(m.span, text, idx, TOKEN_MATH_BLOCK, 0, out),
|
||||
BlockNode::List(ListNode { items, .. }) => {
|
||||
for item in items {
|
||||
emit_list_item(item, text, idx, out);
|
||||
}
|
||||
}
|
||||
BlockNode::DefinitionList(dl) => {
|
||||
for item in &dl.items {
|
||||
if let Some(term) = &item.term {
|
||||
if let (Some(first), Some(last)) = (term.first(), term.last()) {
|
||||
let s = Span::new(span_of_inline(first).start, span_of_inline(last).end);
|
||||
split_span(s, text, idx, TOKEN_DEFINITION_TERM, 0, out);
|
||||
}
|
||||
}
|
||||
for def in &item.definitions {
|
||||
emit_inlines(def, text, idx, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
BlockNode::Table(TableNode { rows, .. }) => {
|
||||
for row in rows {
|
||||
for cell in &row.cells {
|
||||
emit_inlines(&cell.children, text, idx, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
BlockNode::Comment(c) => split_span(c.span, text, idx, TOKEN_COMMENT, 0, out),
|
||||
BlockNode::Tag(t) => split_span(t.span, text, idx, TOKEN_TAG, 0, out),
|
||||
BlockNode::Error(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_heading(h: &HeadingNode, text: &str, idx: &LineIndex, out: &mut Vec<RawToken>) {
|
||||
let mut mods = 0u32;
|
||||
mods |= match h.level {
|
||||
1 => MOD_LEVEL1,
|
||||
2 => MOD_LEVEL2,
|
||||
3 => MOD_LEVEL3,
|
||||
4 => MOD_LEVEL4,
|
||||
5 => MOD_LEVEL5,
|
||||
_ => MOD_LEVEL6,
|
||||
};
|
||||
if h.centered {
|
||||
mods |= MOD_CENTERED;
|
||||
}
|
||||
split_span(h.span, text, idx, TOKEN_HEADING, mods, out);
|
||||
}
|
||||
|
||||
fn emit_list_item(item: &ListItemNode, text: &str, idx: &LineIndex, out: &mut Vec<RawToken>) {
|
||||
emit_inlines(&item.children, text, idx, out);
|
||||
if let Some(sublist) = &item.sublist {
|
||||
for child in &sublist.items {
|
||||
emit_list_item(child, text, idx, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_inlines(inlines: &[InlineNode], text: &str, idx: &LineIndex, out: &mut Vec<RawToken>) {
|
||||
for n in inlines {
|
||||
let (kind, span) = match n {
|
||||
InlineNode::Text(_) => continue,
|
||||
InlineNode::Bold(b) => (TOKEN_BOLD, b.span),
|
||||
InlineNode::Italic(i) => (TOKEN_ITALIC, i.span),
|
||||
InlineNode::BoldItalic(bi) => (TOKEN_BOLD_ITALIC, bi.span),
|
||||
InlineNode::Strikethrough(s) => (TOKEN_STRIKETHROUGH, s.span),
|
||||
InlineNode::Code(c) => (TOKEN_CODE, c.span),
|
||||
InlineNode::Superscript(s) => (TOKEN_SUPERSCRIPT, s.span),
|
||||
InlineNode::Subscript(s) => (TOKEN_SUBSCRIPT, s.span),
|
||||
InlineNode::MathInline(m) => (TOKEN_MATH, m.span),
|
||||
InlineNode::Keyword(k) => (TOKEN_KEYWORD, k.span),
|
||||
InlineNode::Color(c) => (TOKEN_COLOR, c.span),
|
||||
InlineNode::WikiLink(w) => (TOKEN_WIKILINK, w.span),
|
||||
InlineNode::ExternalLink(e) => (TOKEN_EXTERNAL_LINK, e.span),
|
||||
InlineNode::Transclusion(t) => (TOKEN_TRANSCLUSION, t.span),
|
||||
InlineNode::RawUrl(u) => (TOKEN_URL, u.span),
|
||||
};
|
||||
split_span(span, text, idx, kind, 0, out);
|
||||
}
|
||||
}
|
||||
|
||||
fn span_of_inline(node: &InlineNode) -> Span {
|
||||
match node {
|
||||
InlineNode::Text(n) => n.span,
|
||||
InlineNode::Bold(n) => n.span,
|
||||
InlineNode::Italic(n) => n.span,
|
||||
InlineNode::BoldItalic(n) => n.span,
|
||||
InlineNode::Strikethrough(n) => n.span,
|
||||
InlineNode::Code(n) => n.span,
|
||||
InlineNode::Superscript(n) => n.span,
|
||||
InlineNode::Subscript(n) => n.span,
|
||||
InlineNode::MathInline(n) => n.span,
|
||||
InlineNode::Keyword(n) => n.span,
|
||||
InlineNode::Color(n) => n.span,
|
||||
InlineNode::WikiLink(n) => n.span,
|
||||
InlineNode::ExternalLink(n) => n.span,
|
||||
InlineNode::Transclusion(n) => n.span,
|
||||
InlineNode::RawUrl(n) => n.span,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a `Span` (potentially multi-line) into one or more single-line
|
||||
/// `RawToken`s. The LSP wire format can't express tokens that span newlines,
|
||||
/// so we split on line boundaries.
|
||||
pub fn split_span(
|
||||
span: Span,
|
||||
text: &str,
|
||||
idx: &LineIndex,
|
||||
kind: u32,
|
||||
mods: u32,
|
||||
out: &mut Vec<RawToken>,
|
||||
) {
|
||||
if span.start.line == span.end.line {
|
||||
let len = span.end.column.saturating_sub(span.start.column);
|
||||
if len > 0 {
|
||||
out.push(RawToken {
|
||||
line: span.start.line,
|
||||
byte_col: span.start.column,
|
||||
byte_len: len,
|
||||
kind,
|
||||
mods,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
for line in span.start.line..=span.end.line {
|
||||
let line_str = idx.line_str(line, text);
|
||||
let line_len = line_str.len() as u32;
|
||||
let start_col = if line == span.start.line {
|
||||
span.start.column
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let end_col = if line == span.end.line {
|
||||
span.end.column.min(line_len)
|
||||
} else {
|
||||
line_len
|
||||
};
|
||||
if end_col > start_col {
|
||||
out.push(RawToken {
|
||||
line,
|
||||
byte_col: start_col,
|
||||
byte_len: end_col - start_col,
|
||||
kind,
|
||||
mods,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Encoding =====
|
||||
|
||||
/// LSP wire-format encoder. Produces a flat `Vec<u32>` of
|
||||
/// `(deltaLine, deltaStart, length, type, modifiers)` quintuples.
|
||||
///
|
||||
/// `utf8 = true` means the client opted into UTF-8 encoding, so byte offsets
|
||||
/// pass through directly. Otherwise positions and lengths are converted to
|
||||
/// UTF-16 code units per the LSP default.
|
||||
pub fn encode(raws: &[RawToken], text: &str, idx: &LineIndex, utf8: bool) -> Vec<u32> {
|
||||
let mut data = Vec::with_capacity(raws.len() * 5);
|
||||
let mut prev_line = 0u32;
|
||||
let mut prev_col = 0u32;
|
||||
for tok in raws {
|
||||
let (col, len) = if utf8 {
|
||||
(tok.byte_col, tok.byte_len)
|
||||
} else {
|
||||
let line_str = idx.line_str(tok.line, text);
|
||||
let start = utf16_count(line_str, tok.byte_col as usize);
|
||||
let end = utf16_count(line_str, (tok.byte_col + tok.byte_len) as usize);
|
||||
(start, end - start)
|
||||
};
|
||||
let delta_line = tok.line - prev_line;
|
||||
let delta_col = if delta_line == 0 { col - prev_col } else { col };
|
||||
data.extend_from_slice(&[delta_line, delta_col, len, tok.kind, tok.mods]);
|
||||
prev_line = tok.line;
|
||||
prev_col = col;
|
||||
}
|
||||
data
|
||||
}
|
||||
|
||||
fn utf16_count(s: &str, byte_offset: usize) -> u32 {
|
||||
let upto = byte_offset.min(s.len());
|
||||
s[..upto].chars().map(char::len_utf16).sum::<usize>() as u32
|
||||
}
|
||||
|
||||
/// Build the encoded data stream for either the full document or a range
|
||||
/// request.
|
||||
pub fn build_data(doc: &DocumentNode, text: &str, utf8: bool, range: Option<Range>) -> Vec<u32> {
|
||||
let idx = LineIndex::new(text);
|
||||
let mut tokens = collect_tokens(doc, text);
|
||||
if let Some(r) = range {
|
||||
tokens.retain(|t| token_overlaps_range(t, &r, text, &idx, utf8));
|
||||
}
|
||||
encode(&tokens, text, &idx, utf8)
|
||||
}
|
||||
|
||||
fn token_overlaps_range(
|
||||
tok: &RawToken,
|
||||
range: &Range,
|
||||
text: &str,
|
||||
idx: &LineIndex,
|
||||
utf8: bool,
|
||||
) -> bool {
|
||||
let (start_char, end_char) = if utf8 {
|
||||
(tok.byte_col, tok.byte_col + tok.byte_len)
|
||||
} else {
|
||||
let line_str = idx.line_str(tok.line, text);
|
||||
let s = utf16_count(line_str, tok.byte_col as usize);
|
||||
let e = utf16_count(line_str, (tok.byte_col + tok.byte_len) as usize);
|
||||
(s, e)
|
||||
};
|
||||
let tok_start = (tok.line, start_char);
|
||||
let tok_end = (tok.line, end_char);
|
||||
let r_start = (range.start.line, range.start.character);
|
||||
let r_end = (range.end.line, range.end.character);
|
||||
tok_end > r_start && tok_start < r_end
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
//! `Wiki` aggregate — per-wiki state, even when there's only one.
|
||||
//!
|
||||
//! The single `Arc<RwLock<WorkspaceIndex>>` on `Backend` is lifted into a
|
||||
//! `Wiki` aggregate so multi-wiki support only changes config shape, not
|
||||
//! data flow. Handlers always operate on a `Wiki`; in practice the `wikis`
|
||||
//! `Vec` has one entry until multi-wiki support lands.
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use tower_lsp::lsp_types::Url;
|
||||
|
||||
use crate::config::WikiConfig;
|
||||
use crate::index::WorkspaceIndex;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash, Default)]
|
||||
pub struct WikiId(pub u32);
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Wiki {
|
||||
pub id: WikiId,
|
||||
pub config: WikiConfig,
|
||||
pub index: Arc<RwLock<WorkspaceIndex>>,
|
||||
}
|
||||
|
||||
impl Wiki {
|
||||
pub fn new(id: WikiId, config: WikiConfig) -> Self {
|
||||
let index = WorkspaceIndex::new(Some(config.root.clone()))
|
||||
.with_diary_rel_path(Some(config.diary_rel_path.clone()))
|
||||
.with_file_extension(Some(config.file_extension.clone()));
|
||||
let index = Arc::new(RwLock::new(index));
|
||||
Self { id, config, index }
|
||||
}
|
||||
|
||||
/// True when `uri` resolves to a file under this wiki's root.
|
||||
pub fn contains(&self, uri: &Url) -> bool {
|
||||
let Ok(path) = uri.to_file_path() else {
|
||||
return false;
|
||||
};
|
||||
path.starts_with(&self.config.root)
|
||||
}
|
||||
|
||||
/// Length of the wiki's root path (component count) — used to break
|
||||
/// ties in nested-root layouts.
|
||||
pub fn root_depth(&self) -> usize {
|
||||
self.config.root.components().count()
|
||||
}
|
||||
|
||||
pub fn root(&self) -> &Path {
|
||||
&self.config.root
|
||||
}
|
||||
}
|
||||
|
||||
/// Pick the wiki whose root is the longest prefix of `uri`. Returns the
|
||||
/// wiki id, not a guard, so callers can release the read lock before
|
||||
/// awaiting.
|
||||
pub fn resolve_uri_to_wiki(wikis: &[Wiki], uri: &Url) -> Option<WikiId> {
|
||||
wikis
|
||||
.iter()
|
||||
.filter(|w| w.contains(uri))
|
||||
.max_by_key(|w| w.root_depth())
|
||||
.map(|w| w.id)
|
||||
}
|
||||
|
||||
/// Build the initial `Vec<Wiki>` from a list of `WikiConfig`s. Always
|
||||
/// assigns ids by position so `WikiId(0)` is the default wiki.
|
||||
pub fn build_wikis(configs: &[WikiConfig]) -> Vec<Wiki> {
|
||||
configs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, cfg)| Wiki::new(WikiId(i as u32), cfg.clone()))
|
||||
.collect()
|
||||
}
|
||||
@@ -1,524 +0,0 @@
|
||||
//! Checkbox state transitions and the `checkbox_edit` rewriter that
|
||||
//! emits `WorkspaceEdit`s for `nuwiki.list.toggleCheckbox`,
|
||||
//! `nuwiki.list.cycleCheckbox`, and `nuwiki.list.rejectCheckbox`.
|
||||
|
||||
use nuwiki_core::listsyms::ListSyms;
|
||||
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
|
||||
use nuwiki_core::syntax::SyntaxPlugin;
|
||||
use nuwiki_lsp::commands::ops;
|
||||
use tower_lsp::lsp_types::{DocumentChanges, Url};
|
||||
|
||||
fn parse(src: &str) -> nuwiki_core::ast::DocumentNode {
|
||||
VimwikiSyntax::new().parse(src)
|
||||
}
|
||||
|
||||
fn syms() -> ListSyms {
|
||||
ListSyms::default()
|
||||
}
|
||||
|
||||
fn dummy_uri() -> Url {
|
||||
Url::parse("file:///tmp/note.wiki").unwrap()
|
||||
}
|
||||
|
||||
fn one_text_edit(edit: tower_lsp::lsp_types::WorkspaceEdit) -> tower_lsp::lsp_types::TextEdit {
|
||||
let changes = edit.changes.expect("expected changes map");
|
||||
let (_, edits) = changes.into_iter().next().expect("at least one uri");
|
||||
assert_eq!(edits.len(), 1, "expected exactly one text edit");
|
||||
edits.into_iter().next().unwrap()
|
||||
}
|
||||
|
||||
fn doc_changes_for(edit: tower_lsp::lsp_types::WorkspaceEdit) -> Option<DocumentChanges> {
|
||||
edit.document_changes
|
||||
}
|
||||
|
||||
// ===== state transitions =====
|
||||
|
||||
#[test]
|
||||
fn toggle_state_round_trip() {
|
||||
let s = syms();
|
||||
assert_eq!(ops::toggle_state(&s, "[ ]").as_deref(), Some("[X]"));
|
||||
assert_eq!(ops::toggle_state(&s, "[X]").as_deref(), Some("[ ]"));
|
||||
assert_eq!(ops::toggle_state(&s, "[.]").as_deref(), Some("[X]"));
|
||||
assert_eq!(ops::toggle_state(&s, "[-]").as_deref(), Some("[ ]"));
|
||||
assert_eq!(ops::toggle_state(&s, "[?]").as_deref(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cycle_state_walks_progression() {
|
||||
let s = syms();
|
||||
assert_eq!(ops::cycle_state(&s, "[ ]").as_deref(), Some("[.]"));
|
||||
assert_eq!(ops::cycle_state(&s, "[.]").as_deref(), Some("[o]"));
|
||||
assert_eq!(ops::cycle_state(&s, "[o]").as_deref(), Some("[O]"));
|
||||
assert_eq!(ops::cycle_state(&s, "[O]").as_deref(), Some("[X]"));
|
||||
assert_eq!(ops::cycle_state(&s, "[X]").as_deref(), Some("[ ]"));
|
||||
assert_eq!(ops::cycle_state(&s, "[-]").as_deref(), Some("[ ]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cycle_state_back_is_exact_inverse() {
|
||||
// `glp` walks the progression in reverse — every step must undo the
|
||||
// matching `cycle_state` step.
|
||||
let s = syms();
|
||||
assert_eq!(ops::cycle_state_back(&s, "[ ]").as_deref(), Some("[X]"));
|
||||
assert_eq!(ops::cycle_state_back(&s, "[X]").as_deref(), Some("[O]"));
|
||||
assert_eq!(ops::cycle_state_back(&s, "[O]").as_deref(), Some("[o]"));
|
||||
assert_eq!(ops::cycle_state_back(&s, "[o]").as_deref(), Some("[.]"));
|
||||
assert_eq!(ops::cycle_state_back(&s, "[.]").as_deref(), Some("[ ]"));
|
||||
assert_eq!(ops::cycle_state_back(&s, "[-]").as_deref(), Some("[ ]"));
|
||||
// Composing forward then backward returns to the start for every
|
||||
// in-cycle marker.
|
||||
for m in ["[ ]", "[.]", "[o]", "[O]", "[X]"] {
|
||||
let fwd = ops::cycle_state(&s, m).unwrap();
|
||||
assert_eq!(ops::cycle_state_back(&s, &fwd).as_deref(), Some(m));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reject_state_toggles_dash_marker() {
|
||||
let s = syms();
|
||||
assert_eq!(ops::reject_state(&s, "[ ]").as_deref(), Some("[-]"));
|
||||
assert_eq!(ops::reject_state(&s, "[X]").as_deref(), Some("[-]"));
|
||||
assert_eq!(ops::reject_state(&s, "[-]").as_deref(), Some("[ ]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_palette_three_symbols() {
|
||||
// A 3-glyph palette `" x✓"`: empty/half/done. `[x]` is the only
|
||||
// intermediate; cycling walks ` `→`x`→`✓`→` `.
|
||||
let s = ListSyms::new(" x✓");
|
||||
assert_eq!(ops::cycle_state(&s, "[ ]").as_deref(), Some("[x]"));
|
||||
assert_eq!(ops::cycle_state(&s, "[x]").as_deref(), Some("[✓]"));
|
||||
assert_eq!(ops::cycle_state(&s, "[✓]").as_deref(), Some("[ ]"));
|
||||
// Default-palette glyphs are not in this palette → no transition.
|
||||
assert_eq!(ops::cycle_state(&s, "[o]").as_deref(), None);
|
||||
// Toggle still maps empty→done, done→empty.
|
||||
assert_eq!(ops::toggle_state(&s, "[ ]").as_deref(), Some("[✓]"));
|
||||
assert_eq!(ops::toggle_state(&s, "[✓]").as_deref(), Some("[ ]"));
|
||||
}
|
||||
|
||||
// ===== checkbox_edit =====
|
||||
|
||||
#[test]
|
||||
fn toggle_empty_checkbox_to_done() {
|
||||
let src = "- [ ] task\n";
|
||||
let doc = parse(src);
|
||||
let edit = ops::checkbox_edit(
|
||||
src,
|
||||
&doc,
|
||||
&dummy_uri(),
|
||||
0,
|
||||
4,
|
||||
true,
|
||||
&syms(),
|
||||
ops::toggle_state,
|
||||
true,
|
||||
)
|
||||
.expect("toggle edit");
|
||||
let te = one_text_edit(edit);
|
||||
assert_eq!(te.new_text, "[X]");
|
||||
assert_eq!(te.range.start.character, 2); // bytes 2..5 = "[ ]"
|
||||
assert_eq!(te.range.end.character, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cycle_advances_partial_states() {
|
||||
let src = "- [.] half\n";
|
||||
let doc = parse(src);
|
||||
let edit = ops::checkbox_edit(
|
||||
src,
|
||||
&doc,
|
||||
&dummy_uri(),
|
||||
0,
|
||||
0,
|
||||
true,
|
||||
&syms(),
|
||||
ops::cycle_state,
|
||||
true,
|
||||
)
|
||||
.expect("cycle edit");
|
||||
let te = one_text_edit(edit);
|
||||
assert_eq!(te.new_text, "[o]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reject_replaces_empty_with_dash() {
|
||||
let src = "* [ ] thing\n";
|
||||
let doc = parse(src);
|
||||
let edit = ops::checkbox_edit(
|
||||
src,
|
||||
&doc,
|
||||
&dummy_uri(),
|
||||
0,
|
||||
0,
|
||||
true,
|
||||
&syms(),
|
||||
ops::reject_state,
|
||||
true,
|
||||
)
|
||||
.expect("reject edit");
|
||||
let te = one_text_edit(edit);
|
||||
assert_eq!(te.new_text, "[-]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checkbox_edit_returns_none_on_plain_list_item() {
|
||||
// No `[…]` at all → no edit.
|
||||
let src = "- plain item\n";
|
||||
let doc = parse(src);
|
||||
let edit = ops::checkbox_edit(
|
||||
src,
|
||||
&doc,
|
||||
&dummy_uri(),
|
||||
0,
|
||||
0,
|
||||
true,
|
||||
&syms(),
|
||||
ops::toggle_state,
|
||||
true,
|
||||
);
|
||||
assert!(edit.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checkbox_edit_returns_none_off_list() {
|
||||
let src = "just a paragraph\n";
|
||||
let doc = parse(src);
|
||||
let edit = ops::checkbox_edit(
|
||||
src,
|
||||
&doc,
|
||||
&dummy_uri(),
|
||||
0,
|
||||
0,
|
||||
true,
|
||||
&syms(),
|
||||
ops::toggle_state,
|
||||
true,
|
||||
);
|
||||
assert!(edit.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checkbox_edit_finds_item_in_sublist() {
|
||||
let src = "- top\n - [ ] nested\n";
|
||||
let doc = parse(src);
|
||||
// Cursor on line 1, the nested item.
|
||||
let edit = ops::checkbox_edit(
|
||||
src,
|
||||
&doc,
|
||||
&dummy_uri(),
|
||||
1,
|
||||
5,
|
||||
true,
|
||||
&syms(),
|
||||
ops::toggle_state,
|
||||
true,
|
||||
)
|
||||
.expect("edit on nested item");
|
||||
let te = one_text_edit(edit);
|
||||
assert_eq!(te.new_text, "[X]");
|
||||
assert_eq!(te.range.start.line, 1);
|
||||
}
|
||||
|
||||
// ===== sanity: workspace edit shape =====
|
||||
|
||||
#[test]
|
||||
fn checkbox_edit_uses_changes_map_not_document_changes() {
|
||||
let src = "- [ ] task\n";
|
||||
let doc = parse(src);
|
||||
let edit = ops::checkbox_edit(
|
||||
src,
|
||||
&doc,
|
||||
&dummy_uri(),
|
||||
0,
|
||||
4,
|
||||
true,
|
||||
&syms(),
|
||||
ops::toggle_state,
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(doc_changes_for(edit.clone()).is_none());
|
||||
assert!(edit.changes.is_some());
|
||||
}
|
||||
|
||||
// ===== parent propagation (vimwiki listsyms_propagate) =====
|
||||
|
||||
/// Collect the (line, new_text) pairs for every TextEdit in the result,
|
||||
/// sorted by line. Lets propagation tests assert the parent edit
|
||||
/// without caring about edit ordering.
|
||||
fn edits_by_line(edit: tower_lsp::lsp_types::WorkspaceEdit) -> Vec<(u32, String)> {
|
||||
let changes = edit.changes.expect("expected changes map");
|
||||
let (_, edits) = changes.into_iter().next().expect("at least one uri");
|
||||
let mut pairs: Vec<(u32, String)> = edits
|
||||
.into_iter()
|
||||
.map(|e| (e.range.start.line, e.new_text))
|
||||
.collect();
|
||||
pairs.sort_by_key(|(line, _)| *line);
|
||||
pairs
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn propagate_one_of_four_done_marks_parent_quarter() {
|
||||
// Toggle the first child of a 4-child parent. Parent should land at
|
||||
// [.] (Quarter, 25%).
|
||||
let src = "\
|
||||
- [ ] root
|
||||
- [ ] sub1
|
||||
- [ ] sub2
|
||||
- [ ] sub3
|
||||
- [ ] sub4
|
||||
";
|
||||
let doc = parse(src);
|
||||
let edit = ops::checkbox_edit(
|
||||
src,
|
||||
&doc,
|
||||
&dummy_uri(),
|
||||
1,
|
||||
6,
|
||||
true,
|
||||
&syms(),
|
||||
ops::toggle_state,
|
||||
true,
|
||||
)
|
||||
.expect("toggle edit");
|
||||
let pairs = edits_by_line(edit);
|
||||
assert_eq!(pairs, vec![(0, "[.]".into()), (1, "[X]".into())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn propagate_two_of_four_done_marks_parent_half() {
|
||||
// sub1 is already done; toggling sub2 → done leaves rate=50% → [o].
|
||||
let src = "\
|
||||
- [ ] root
|
||||
- [X] sub1
|
||||
- [ ] sub2
|
||||
- [ ] sub3
|
||||
- [ ] sub4
|
||||
";
|
||||
let doc = parse(src);
|
||||
let edit = ops::checkbox_edit(
|
||||
src,
|
||||
&doc,
|
||||
&dummy_uri(),
|
||||
2,
|
||||
6,
|
||||
true,
|
||||
&syms(),
|
||||
ops::toggle_state,
|
||||
true,
|
||||
)
|
||||
.expect("toggle edit");
|
||||
let pairs = edits_by_line(edit);
|
||||
assert_eq!(pairs, vec![(0, "[o]".into()), (2, "[X]".into())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn propagate_three_of_four_done_marks_parent_three_quarters() {
|
||||
let src = "\
|
||||
- [ ] root
|
||||
- [X] sub1
|
||||
- [X] sub2
|
||||
- [ ] sub3
|
||||
- [ ] sub4
|
||||
";
|
||||
let doc = parse(src);
|
||||
let edit = ops::checkbox_edit(
|
||||
src,
|
||||
&doc,
|
||||
&dummy_uri(),
|
||||
3,
|
||||
6,
|
||||
true,
|
||||
&syms(),
|
||||
ops::toggle_state,
|
||||
true,
|
||||
)
|
||||
.expect("toggle edit");
|
||||
let pairs = edits_by_line(edit);
|
||||
assert_eq!(pairs, vec![(0, "[O]".into()), (3, "[X]".into())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn propagate_all_done_marks_parent_done() {
|
||||
let src = "\
|
||||
- [ ] root
|
||||
- [X] sub1
|
||||
- [X] sub2
|
||||
- [X] sub3
|
||||
- [ ] sub4
|
||||
";
|
||||
let doc = parse(src);
|
||||
let edit = ops::checkbox_edit(
|
||||
src,
|
||||
&doc,
|
||||
&dummy_uri(),
|
||||
4,
|
||||
6,
|
||||
true,
|
||||
&syms(),
|
||||
ops::toggle_state,
|
||||
true,
|
||||
)
|
||||
.expect("toggle edit");
|
||||
let pairs = edits_by_line(edit);
|
||||
assert_eq!(pairs, vec![(0, "[X]".into()), (4, "[X]".into())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn propagate_untoggle_drops_parent_back() {
|
||||
// Untoggling the only completed child should put parent back to [ ].
|
||||
let src = "\
|
||||
- [.] root
|
||||
- [X] sub1
|
||||
- [ ] sub2
|
||||
- [ ] sub3
|
||||
- [ ] sub4
|
||||
";
|
||||
let doc = parse(src);
|
||||
let edit = ops::checkbox_edit(
|
||||
src,
|
||||
&doc,
|
||||
&dummy_uri(),
|
||||
1,
|
||||
6,
|
||||
true,
|
||||
&syms(),
|
||||
ops::toggle_state,
|
||||
true,
|
||||
)
|
||||
.expect("toggle edit");
|
||||
let pairs = edits_by_line(edit);
|
||||
assert_eq!(pairs, vec![(0, "[ ]".into()), (1, "[ ]".into())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn propagate_disabled_only_emits_leaf_edit() {
|
||||
let src = "\
|
||||
- [ ] root
|
||||
- [ ] sub1
|
||||
- [ ] sub2
|
||||
- [ ] sub3
|
||||
- [ ] sub4
|
||||
";
|
||||
let doc = parse(src);
|
||||
let edit = ops::checkbox_edit(
|
||||
src,
|
||||
&doc,
|
||||
&dummy_uri(),
|
||||
1,
|
||||
6,
|
||||
true,
|
||||
&syms(),
|
||||
ops::toggle_state,
|
||||
false,
|
||||
)
|
||||
.expect("toggle edit");
|
||||
let pairs = edits_by_line(edit);
|
||||
assert_eq!(pairs, vec![(1, "[X]".into())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn propagate_skips_parent_without_checkbox() {
|
||||
// Parent has no `[…]`, so propagation has nothing to update.
|
||||
let src = "\
|
||||
- root
|
||||
- [ ] sub1
|
||||
- [ ] sub2
|
||||
";
|
||||
let doc = parse(src);
|
||||
let edit = ops::checkbox_edit(
|
||||
src,
|
||||
&doc,
|
||||
&dummy_uri(),
|
||||
1,
|
||||
6,
|
||||
true,
|
||||
&syms(),
|
||||
ops::toggle_state,
|
||||
true,
|
||||
)
|
||||
.expect("toggle edit");
|
||||
let pairs = edits_by_line(edit);
|
||||
assert_eq!(pairs, vec![(1, "[X]".into())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn propagate_walks_multiple_levels() {
|
||||
// Toggle the deepest leaf: grandparent has 1 child (parent), parent
|
||||
// has 2 children. Sub1 done → parent half → grandparent half.
|
||||
let src = "\
|
||||
- [ ] grand
|
||||
- [ ] parent
|
||||
- [ ] sub1
|
||||
- [ ] sub2
|
||||
";
|
||||
let doc = parse(src);
|
||||
let edit = ops::checkbox_edit(
|
||||
src,
|
||||
&doc,
|
||||
&dummy_uri(),
|
||||
2,
|
||||
8,
|
||||
true,
|
||||
&syms(),
|
||||
ops::toggle_state,
|
||||
true,
|
||||
)
|
||||
.expect("toggle edit");
|
||||
let pairs = edits_by_line(edit);
|
||||
assert_eq!(
|
||||
pairs,
|
||||
vec![(0, "[o]".into()), (1, "[o]".into()), (2, "[X]".into())]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn propagate_all_rejected_marks_parent_rejected() {
|
||||
// Every counted child is rejected → parent becomes [-].
|
||||
let src = "\
|
||||
- [ ] root
|
||||
- [-] sub1
|
||||
- [ ] sub2
|
||||
";
|
||||
let doc = parse(src);
|
||||
// Reject sub2 so both children are rejected.
|
||||
let edit = ops::checkbox_edit(
|
||||
src,
|
||||
&doc,
|
||||
&dummy_uri(),
|
||||
2,
|
||||
6,
|
||||
true,
|
||||
&syms(),
|
||||
ops::reject_state,
|
||||
true,
|
||||
)
|
||||
.expect("reject edit");
|
||||
let pairs = edits_by_line(edit);
|
||||
assert_eq!(pairs, vec![(0, "[-]".into()), (2, "[-]".into())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn propagate_rejected_sibling_counts_as_done_for_average() {
|
||||
// sub1 rejected, sub2 toggled to done → both "complete" → parent [X].
|
||||
let src = "\
|
||||
- [ ] root
|
||||
- [-] sub1
|
||||
- [ ] sub2
|
||||
";
|
||||
let doc = parse(src);
|
||||
let edit = ops::checkbox_edit(
|
||||
src,
|
||||
&doc,
|
||||
&dummy_uri(),
|
||||
2,
|
||||
6,
|
||||
true,
|
||||
&syms(),
|
||||
ops::toggle_state,
|
||||
true,
|
||||
)
|
||||
.expect("toggle edit");
|
||||
let pairs = edits_by_line(edit);
|
||||
assert_eq!(pairs, vec![(0, "[X]".into()), (2, "[X]".into())]);
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
//! `color_dic` → `HtmlRenderer` integration.
|
||||
//!
|
||||
//! Drives the renderer directly so we don't have to spin up the full
|
||||
//! export pipeline. The end-to-end path (export command → renderer
|
||||
//! with `cfg.color_dic` plumbed through) is integration-tested via the
|
||||
//! export tests.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use nuwiki_core::ast::{ColorNode, DocumentNode, InlineNode, ParagraphNode, Span, TextNode};
|
||||
use nuwiki_core::render::{HtmlRenderer, Renderer};
|
||||
use nuwiki_lsp::config::config_from_json;
|
||||
|
||||
fn doc_with_color(name: &str, text: &str) -> DocumentNode {
|
||||
let span = Span::default();
|
||||
DocumentNode {
|
||||
span,
|
||||
metadata: Default::default(),
|
||||
children: vec![nuwiki_core::ast::BlockNode::Paragraph(ParagraphNode {
|
||||
span,
|
||||
children: vec![InlineNode::Color(ColorNode {
|
||||
span,
|
||||
color: name.into(),
|
||||
children: vec![InlineNode::Text(TextNode {
|
||||
span,
|
||||
content: text.into(),
|
||||
})],
|
||||
})],
|
||||
})],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn color_falls_back_to_class_when_dict_empty() {
|
||||
let doc = doc_with_color("red", "hello");
|
||||
let r = HtmlRenderer::new();
|
||||
let out = r.render_to_string(&doc).unwrap();
|
||||
assert!(out.contains(r#"<span class="color-red">"#), "got: {out}");
|
||||
assert!(out.contains("hello</span>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn color_dic_emits_inline_style_when_name_listed() {
|
||||
let mut dict = HashMap::new();
|
||||
dict.insert("red".to_string(), "#ff0000".to_string());
|
||||
let doc = doc_with_color("red", "hello");
|
||||
let r = HtmlRenderer::new().with_colors(dict);
|
||||
let out = r.render_to_string(&doc).unwrap();
|
||||
assert!(
|
||||
out.contains(r#"<span style="color:#ff0000">"#),
|
||||
"got: {out}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unlisted_color_falls_back_even_with_dict_set() {
|
||||
let mut dict = HashMap::new();
|
||||
dict.insert("red".to_string(), "#ff0000".to_string());
|
||||
let doc = doc_with_color("violet", "x");
|
||||
let r = HtmlRenderer::new().with_colors(dict);
|
||||
let out = r.render_to_string(&doc).unwrap();
|
||||
assert!(out.contains(r#"<span class="color-violet">"#), "got: {out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_from_json_accepts_color_dic() {
|
||||
let cfg = config_from_json(serde_json::json!({
|
||||
"wikis": [
|
||||
{ "root": "/tmp/c", "color_dic": { "red": "red", "blue": "blue" } },
|
||||
],
|
||||
}));
|
||||
let d = &cfg.wikis[0].html.color_dic;
|
||||
assert_eq!(d.get("red").map(String::as_str), Some("red"));
|
||||
assert_eq!(d.get("blue").map(String::as_str), Some("blue"));
|
||||
}
|
||||
@@ -1,558 +0,0 @@
|
||||
//! Command-coverage suite: at least one behavioural test for every
|
||||
//! documented `:Nuwiki*` / `:Vimwiki*` command (see the COMMANDS section
|
||||
//! of `doc/nuwiki.txt`).
|
||||
//!
|
||||
//! The user-facing commands funnel through `workspace/executeCommand`
|
||||
//! into the Backend dispatcher, or — for follow/backlinks/rename — through
|
||||
//! the standard LSP requests. The Backend itself owns a live `Client` and
|
||||
//! can't be built in an integration test, so each case drives the *pure*
|
||||
//! building-block the command relies on (the same functions the dispatcher
|
||||
//! wraps). This file is organised to mirror the doc's command groups so a
|
||||
//! reader can confirm every command has coverage.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use nuwiki_core::ast::{InlineNode, LinkKind, LinkTarget};
|
||||
use nuwiki_core::date::DiaryDate;
|
||||
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
|
||||
use nuwiki_core::syntax::SyntaxPlugin;
|
||||
use nuwiki_lsp::commands::{export_ops, ops, COMMANDS};
|
||||
use nuwiki_lsp::config::WikiConfig;
|
||||
use nuwiki_lsp::index::WorkspaceIndex;
|
||||
use nuwiki_lsp::nav::find_inline_at;
|
||||
use nuwiki_lsp::rename::{apply_links_space_char, build_new_uri, rewrite_wikilink_target};
|
||||
use nuwiki_lsp::wiki::{build_wikis, resolve_uri_to_wiki};
|
||||
use nuwiki_lsp::{diary, export};
|
||||
use tower_lsp::lsp_types::Url;
|
||||
|
||||
fn parse(src: &str) -> nuwiki_core::ast::DocumentNode {
|
||||
VimwikiSyntax::new().parse(src)
|
||||
}
|
||||
|
||||
fn wiki_cfg(root: &str) -> WikiConfig {
|
||||
WikiConfig::from_root(PathBuf::from(root))
|
||||
}
|
||||
|
||||
/// Build a workspace index rooted at `root` with the given `name -> source`
|
||||
/// pages, mirroring the helper used by the other command suites.
|
||||
fn build_index(root: &str, pages: &[(&str, &str)]) -> WorkspaceIndex {
|
||||
let mut idx = WorkspaceIndex::new(Some(PathBuf::from(root)));
|
||||
for (name, src) in pages {
|
||||
let uri = Url::from_file_path(format!("{root}/{name}.wiki")).unwrap();
|
||||
idx.upsert(uri, &parse(src));
|
||||
}
|
||||
idx
|
||||
}
|
||||
|
||||
fn diary_index(root: &str, dates: &[&str]) -> WorkspaceIndex {
|
||||
let mut idx =
|
||||
WorkspaceIndex::new(Some(PathBuf::from(root))).with_diary_rel_path(Some("diary".into()));
|
||||
for d in dates {
|
||||
let uri = Url::from_file_path(format!("{root}/diary/{d}.wiki")).unwrap();
|
||||
idx.upsert(uri, &parse("= entry =\n"));
|
||||
}
|
||||
idx
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Group 1: Wiki / navigation
|
||||
// =====================================================================
|
||||
|
||||
// :NuwikiIndex — open wiki N's index page.
|
||||
#[test]
|
||||
fn nuwiki_index_resolves_index_page() {
|
||||
let idx = build_index("/wiki", &[("index", "= Home =\n"), ("Other", "= O =\n")]);
|
||||
let page = idx.page_by_name("index").expect("index page indexed");
|
||||
assert!(page.uri.as_str().ends_with("/wiki/index.wiki"));
|
||||
}
|
||||
|
||||
// :NuwikiTabIndex — same target as :NuwikiIndex, opened in a new tab.
|
||||
// The tab-vs-current split is an editor concern; the resolved target is
|
||||
// identical, and both verbs are advertised as executeCommands.
|
||||
#[test]
|
||||
fn nuwiki_tab_index_advertised_alongside_index() {
|
||||
assert!(COMMANDS.contains(&"nuwiki.wiki.openIndex"));
|
||||
assert!(COMMANDS.contains(&"nuwiki.wiki.tabOpenIndex"));
|
||||
}
|
||||
|
||||
// :NuwikiUISelect — pick a wiki from the configured list.
|
||||
#[test]
|
||||
fn nuwiki_ui_select_lists_configured_wikis() {
|
||||
let cfgs = vec![wiki_cfg("/a/personal"), wiki_cfg("/b/work")];
|
||||
let wikis = build_wikis(&cfgs);
|
||||
assert_eq!(wikis.len(), 2);
|
||||
assert_eq!(wikis[0].config.name, "personal");
|
||||
assert_eq!(wikis[1].config.name, "work");
|
||||
|
||||
// Selection by URI picks the wiki that actually owns the file.
|
||||
let uri = Url::from_file_path("/b/work/Page.wiki").unwrap();
|
||||
assert_eq!(resolve_uri_to_wiki(&wikis, &uri), Some(wikis[1].id));
|
||||
}
|
||||
|
||||
// :NuwikiGoto {page} — open `{page}.wiki` by name.
|
||||
#[test]
|
||||
fn nuwiki_goto_opens_page_by_name() {
|
||||
let idx = build_index("/wiki", &[("index", "= H =\n"), ("Recipes", "= R =\n")]);
|
||||
let page = idx
|
||||
.page_by_name("Recipes")
|
||||
.expect("page resolvable by name");
|
||||
assert!(page.uri.as_str().ends_with("/wiki/Recipes.wiki"));
|
||||
assert!(idx.page_by_name("Missing").is_none());
|
||||
}
|
||||
|
||||
// :NuwikiFollowLink — follow the link under the cursor.
|
||||
#[test]
|
||||
fn nuwiki_follow_link_resolves_target_under_cursor() {
|
||||
let idx = build_index(
|
||||
"/wiki",
|
||||
&[("Home", "see [[About]]\n"), ("About", "= A =\n")],
|
||||
);
|
||||
let doc = parse("see [[About]]\n");
|
||||
// Byte col 8 sits inside "[[About]]" (starts at byte 4).
|
||||
let node = find_inline_at(&doc, 0, 8).expect("wikilink under cursor");
|
||||
let InlineNode::WikiLink(w) = node else {
|
||||
panic!("expected a wikilink, got {node:?}");
|
||||
};
|
||||
let about = Url::from_file_path("/wiki/About.wiki").unwrap();
|
||||
assert_eq!(idx.resolve(&w.target, "Home"), Some(&about));
|
||||
}
|
||||
|
||||
// :NuwikiBacklinks — every reference to the current page.
|
||||
#[test]
|
||||
fn nuwiki_backlinks_lists_referrers() {
|
||||
let idx = build_index(
|
||||
"/wiki",
|
||||
&[
|
||||
("Home", "[[About]]\n"),
|
||||
("Contact", "see [[About]] too\n"),
|
||||
("About", "= A =\n"),
|
||||
],
|
||||
);
|
||||
let back = idx.backlinks_for("About");
|
||||
assert_eq!(back.len(), 2);
|
||||
assert!(idx.backlinks_for("Home").is_empty());
|
||||
}
|
||||
|
||||
// :NuwikiNextLink / :NuwikiPrevLink — jump to the next / previous wikilink.
|
||||
// The jump itself is editor-side cursor movement; what the server provides
|
||||
// is the ordered set of links on the page.
|
||||
#[test]
|
||||
fn nuwiki_next_prev_link_walk_links_in_document_order() {
|
||||
let doc = parse("intro [[First]] mid [[Second]] end [[Third]]\n");
|
||||
let links = ops::collect_wiki_links(&doc);
|
||||
let targets: Vec<&str> = links
|
||||
.iter()
|
||||
.filter_map(|l| l.target.path.as_deref())
|
||||
.collect();
|
||||
assert_eq!(targets, vec!["First", "Second", "Third"]);
|
||||
}
|
||||
|
||||
// :NuwikiBaddLink — add the target of the link under the cursor to the
|
||||
// buffer list. The editor :badds whatever URI the link resolves to.
|
||||
#[test]
|
||||
fn nuwiki_badd_link_resolves_target_uri() {
|
||||
let idx = build_index(
|
||||
"/wiki",
|
||||
&[("Home", "[[Notes/Todo]]\n"), ("Notes/Todo", "= T =\n")],
|
||||
);
|
||||
let target = LinkTarget {
|
||||
kind: LinkKind::Wiki,
|
||||
path: Some("Notes/Todo".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let expected = Url::from_file_path("/wiki/Notes/Todo.wiki").unwrap();
|
||||
assert_eq!(idx.resolve(&target, "Home"), Some(&expected));
|
||||
}
|
||||
|
||||
// :NuwikiRenameFile — rename the page and rewrite every inbound link.
|
||||
#[test]
|
||||
fn nuwiki_rename_file_builds_uri_and_rewrites_links() {
|
||||
let new_uri = build_new_uri(&PathBuf::from("/wiki"), "New Name", ".wiki").unwrap();
|
||||
assert!(new_uri.as_str().ends_with("/wiki/New%20Name.wiki"));
|
||||
|
||||
assert_eq!(
|
||||
rewrite_wikilink_target("[[Old Name|caption]]", "New Name"),
|
||||
Some("[[New Name|caption]]".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
// links_space_char rewrites spaces in the link target / on-disk path while
|
||||
// the default of a single space keeps the name verbatim.
|
||||
#[test]
|
||||
fn links_space_char_replaces_spaces_in_target_path() {
|
||||
// Default " " — identity, spaces preserved.
|
||||
assert_eq!(apply_links_space_char("My Page", " "), "My Page");
|
||||
// Custom char — spaces become the configured glyph.
|
||||
assert_eq!(apply_links_space_char("My Page", "_"), "My_Page");
|
||||
// Subdir separators are untouched; only spaces within segments change.
|
||||
assert_eq!(
|
||||
apply_links_space_char("sub dir/My Page", "_"),
|
||||
"sub_dir/My_Page"
|
||||
);
|
||||
|
||||
// The transformed name flows into both the new URI and the rewritten link.
|
||||
let renamed = apply_links_space_char("New Page", "-");
|
||||
let new_uri = build_new_uri(&PathBuf::from("/wiki"), &renamed, ".wiki").unwrap();
|
||||
assert!(new_uri.as_str().ends_with("/wiki/New-Page.wiki"));
|
||||
assert_eq!(
|
||||
rewrite_wikilink_target("[[Old Name|caption]]", &renamed),
|
||||
Some("[[New-Page|caption]]".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
// :NuwikiDeleteFile — delete the current page and its on-disk file.
|
||||
#[test]
|
||||
fn nuwiki_delete_file_emits_delete_workspace_edit() {
|
||||
use tower_lsp::lsp_types::{DocumentChangeOperation, DocumentChanges, ResourceOp};
|
||||
let uri = Url::parse("file:///wiki/Doomed.wiki").unwrap();
|
||||
let mut b = nuwiki_lsp::edits::WorkspaceEditBuilder::new();
|
||||
b.file_op(nuwiki_lsp::edits::op_delete(uri.clone()));
|
||||
let we = b.build();
|
||||
|
||||
let DocumentChanges::Operations(ops) = we.document_changes.unwrap() else {
|
||||
panic!("expected resource operations");
|
||||
};
|
||||
assert!(matches!(
|
||||
&ops[0],
|
||||
DocumentChangeOperation::Op(ResourceOp::Delete(d)) if d.uri == uri
|
||||
));
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Group 2: Diary
|
||||
// =====================================================================
|
||||
|
||||
// :NuwikiMakeDiaryNote — open today's entry.
|
||||
#[test]
|
||||
fn nuwiki_make_diary_note_targets_todays_file() {
|
||||
let cfg = wiki_cfg("/wiki");
|
||||
let today = DiaryDate::from_ymd(2026, 5, 12).unwrap();
|
||||
let uri = diary::uri_for_date(&cfg, &today).unwrap();
|
||||
assert!(uri.as_str().ends_with("/diary/2026-05-12.wiki"));
|
||||
}
|
||||
|
||||
// :NuwikiMakeYesterdayDiaryNote / :NuwikiMakeTomorrowDiaryNote — step the
|
||||
// diary back / forward by one period at the daily cadence.
|
||||
#[test]
|
||||
fn nuwiki_yesterday_and_tomorrow_target_adjacent_files() {
|
||||
let cfg = wiki_cfg("/wiki");
|
||||
let yesterday = DiaryDate::from_ymd(2026, 5, 11).unwrap();
|
||||
let tomorrow = DiaryDate::from_ymd(2026, 5, 13).unwrap();
|
||||
assert!(diary::uri_for_date(&cfg, &yesterday)
|
||||
.unwrap()
|
||||
.as_str()
|
||||
.ends_with("/diary/2026-05-11.wiki"));
|
||||
assert!(diary::uri_for_date(&cfg, &tomorrow)
|
||||
.unwrap()
|
||||
.as_str()
|
||||
.ends_with("/diary/2026-05-13.wiki"));
|
||||
}
|
||||
|
||||
// :NuwikiDiaryIndex — open the diary index page.
|
||||
#[test]
|
||||
fn nuwiki_diary_index_resolves_index_uri() {
|
||||
let cfg = wiki_cfg("/wiki");
|
||||
let uri = diary::index_uri(&cfg).unwrap();
|
||||
assert!(uri.as_str().ends_with("/diary/diary.wiki"));
|
||||
}
|
||||
|
||||
// :NuwikiDiaryGenerateLinks — rebuild the diary index from disk entries.
|
||||
#[test]
|
||||
fn nuwiki_diary_generate_links_builds_grouped_body() {
|
||||
let idx = diary_index("/wiki", &["2026-05-10", "2026-05-12", "2026-04-30"]);
|
||||
let entries = diary::list_entries(&idx);
|
||||
let body = diary::build_index_body(&entries, "diary", "Diary", diary::DiarySort::Desc, 1);
|
||||
assert!(body.starts_with("= Diary ="));
|
||||
assert!(body.contains("2026-05-12"));
|
||||
assert!(body.contains("2026-05-10"));
|
||||
assert!(body.contains("2026-04-30"));
|
||||
// Newest entry appears before the older one within the same month.
|
||||
let p12 = body.find("2026-05-12").unwrap();
|
||||
let p10 = body.find("2026-05-10").unwrap();
|
||||
assert!(p12 < p10, "expected newest-first ordering");
|
||||
}
|
||||
|
||||
// :NuwikiDiaryNextDay / :NuwikiDiaryPrevDay — walk indexed entries.
|
||||
#[test]
|
||||
fn nuwiki_diary_next_and_prev_day_walk_entries() {
|
||||
let idx = diary_index("/wiki", &["2026-05-10", "2026-05-12", "2026-05-15"]);
|
||||
let from = DiaryDate::from_ymd(2026, 5, 12).unwrap();
|
||||
assert_eq!(
|
||||
diary::next_entry(&idx, &from).unwrap().date.format(),
|
||||
"2026-05-15"
|
||||
);
|
||||
assert_eq!(
|
||||
diary::prev_entry(&idx, &from).unwrap().date.format(),
|
||||
"2026-05-10"
|
||||
);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Group 3: Page generation
|
||||
// =====================================================================
|
||||
|
||||
// :NuwikiTOC — generate / refresh the table of contents.
|
||||
#[test]
|
||||
fn nuwiki_toc_generates_nested_contents() {
|
||||
let src = "= Top =\n== Sub ==\ntext\n";
|
||||
let doc = parse(src);
|
||||
let uri = Url::from_file_path("/wiki/Page.wiki").unwrap();
|
||||
let edit = ops::toc_edit(src, &doc, &uri, true).expect("toc edit produced");
|
||||
assert!(edit.changes.is_some() || edit.document_changes.is_some());
|
||||
|
||||
let toc = ops::build_toc_text(
|
||||
&[
|
||||
(1, "Top".into(), "top".into()),
|
||||
(2, "Sub".into(), "sub".into()),
|
||||
],
|
||||
"Contents",
|
||||
);
|
||||
assert!(toc.contains("= Contents ="));
|
||||
assert!(toc.contains("- [[#top|Top]]"));
|
||||
assert!(toc.contains(" - [[#sub|Sub]]"));
|
||||
}
|
||||
|
||||
// :NuwikiGenerateLinks — insert a flat list of every page in the wiki.
|
||||
#[test]
|
||||
fn nuwiki_generate_links_lists_all_pages_excluding_current() {
|
||||
let pages = vec!["About".to_string(), "Home".to_string(), "Notes".to_string()];
|
||||
let text = ops::build_links_text(&pages, "Links", Some("Home"));
|
||||
assert!(text.contains("= Links ="));
|
||||
assert!(text.contains("- [[About]]"));
|
||||
assert!(text.contains("- [[Notes]]"));
|
||||
assert!(!text.contains("[[Home]]"), "current page excluded");
|
||||
|
||||
let src = "= Existing =\n";
|
||||
let doc = parse(src);
|
||||
let uri = Url::from_file_path("/wiki/Home.wiki").unwrap();
|
||||
assert!(ops::links_edit(src, &doc, &uri, "Home", &pages, true).is_some());
|
||||
}
|
||||
|
||||
// :NuwikiCheckLinks — surface broken links across the workspace.
|
||||
#[test]
|
||||
fn nuwiki_check_links_distinguishes_broken_from_resolvable() {
|
||||
let idx = build_index(
|
||||
"/wiki",
|
||||
&[("Home", "[[About]] [[Ghost]]\n"), ("About", "= A =\n")],
|
||||
);
|
||||
let good = LinkTarget {
|
||||
kind: LinkKind::Wiki,
|
||||
path: Some("About".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let broken = LinkTarget {
|
||||
kind: LinkKind::Wiki,
|
||||
path: Some("Ghost".into()),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(idx.resolve(&good, "Home").is_some());
|
||||
assert!(
|
||||
idx.resolve(&broken, "Home").is_none(),
|
||||
"broken link unresolved"
|
||||
);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Group 4: Tags
|
||||
// =====================================================================
|
||||
|
||||
// :NuwikiSearchTags {tag} — every `:tag:` occurrence.
|
||||
#[test]
|
||||
fn nuwiki_search_tags_finds_occurrences() {
|
||||
let idx = build_index(
|
||||
"/wiki",
|
||||
&[("Home", ":alpha:beta:\n"), ("Work", ":alpha:\n")],
|
||||
);
|
||||
let hits = ops::tag_hits(&idx, "alpha");
|
||||
assert_eq!(hits.len(), 2);
|
||||
assert!(hits.iter().all(|h| h.name == "alpha"));
|
||||
assert!(ops::tag_hits(&idx, "nonexistent").is_empty());
|
||||
}
|
||||
|
||||
// :NuwikiGenerateTagLinks [tag] — section linking every page with a tag.
|
||||
#[test]
|
||||
fn nuwiki_generate_tag_links_builds_section() {
|
||||
let idx = build_index(
|
||||
"/wiki",
|
||||
&[
|
||||
("Home", ":alpha:\n"),
|
||||
("Work", ":alpha:\n"),
|
||||
("Misc", ":beta:\n"),
|
||||
],
|
||||
);
|
||||
let by_tag = ops::tag_pages_snapshot(&idx);
|
||||
|
||||
let single = ops::build_tag_links_text(&by_tag, Some("alpha")).unwrap();
|
||||
assert!(single.starts_with("= Tag: alpha ="));
|
||||
assert!(single.contains("- [[Home]]"));
|
||||
assert!(single.contains("- [[Work]]"));
|
||||
|
||||
// No-arg variant emits a section per tag.
|
||||
let all = ops::build_tag_links_text(&by_tag, None).unwrap();
|
||||
assert!(all.starts_with("= Tags ="));
|
||||
assert!(all.contains("== alpha =="));
|
||||
assert!(all.contains("== beta =="));
|
||||
|
||||
let src = "= Home =\n";
|
||||
let doc = parse(src);
|
||||
let uri = Url::from_file_path("/wiki/Home.wiki").unwrap();
|
||||
assert!(ops::tag_links_edit(src, &doc, &uri, Some("alpha"), &by_tag, true).is_some());
|
||||
}
|
||||
|
||||
// :NuwikiRebuildTags — force a full workspace re-index. A rebuild must
|
||||
// reflect the current on-disk tags, dropping stale ones.
|
||||
#[test]
|
||||
fn nuwiki_rebuild_tags_reflects_current_state() {
|
||||
let mut idx = WorkspaceIndex::new(Some(PathBuf::from("/wiki")));
|
||||
let uri = Url::from_file_path("/wiki/Home.wiki").unwrap();
|
||||
idx.upsert(uri.clone(), &parse(":old:\n"));
|
||||
assert_eq!(idx.tags_for("old").len(), 1);
|
||||
|
||||
// Re-index after the page changed on disk.
|
||||
idx.upsert(uri, &parse(":new:\n"));
|
||||
assert!(idx.tags_for("old").is_empty(), "stale tag dropped");
|
||||
assert_eq!(idx.tags_for("new").len(), 1);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Group 5: HTML export
|
||||
// =====================================================================
|
||||
|
||||
// :Nuwiki2HTML — render the current page to HTML.
|
||||
#[test]
|
||||
fn nuwiki_2html_renders_page() {
|
||||
let cfg = wiki_cfg("/wiki");
|
||||
let doc = parse("%title My Page\n= One =\nbody text\n");
|
||||
let html = export::render_page_html(
|
||||
&doc,
|
||||
Some("<title>{{title}}</title><main>{{content}}</main>".into()),
|
||||
"Home",
|
||||
DiaryDate::from_ymd(2026, 5, 12).unwrap(),
|
||||
&cfg.html,
|
||||
Some(".wiki"),
|
||||
-1,
|
||||
HashMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(html.contains("<title>My Page</title>"));
|
||||
assert!(html.contains("body text"));
|
||||
}
|
||||
|
||||
// :Nuwiki2HTMLBrowse — render then open in the browser. The render output
|
||||
// is identical to :Nuwiki2HTML; the browse step is an editor-side open.
|
||||
#[test]
|
||||
fn nuwiki_2html_browse_shares_render_and_is_advertised() {
|
||||
assert!(COMMANDS.contains(&"nuwiki.export.browse"));
|
||||
let cfg = wiki_cfg("/wiki");
|
||||
let doc = parse("= Page =\nhi\n");
|
||||
let html = export::render_page_html(
|
||||
&doc,
|
||||
Some("{{content}}".into()),
|
||||
"Page",
|
||||
DiaryDate::today_utc(),
|
||||
&cfg.html,
|
||||
Some(".wiki"),
|
||||
-1,
|
||||
HashMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(html.contains("hi"));
|
||||
}
|
||||
|
||||
// :NuwikiAll2HTML[!] — export every page; honour the exclude list and
|
||||
// per-page output paths.
|
||||
#[test]
|
||||
fn nuwiki_all2html_maps_output_paths_and_excludes() {
|
||||
let cfg = wiki_cfg("/wiki");
|
||||
assert!(export::output_path_for(&cfg.html, "Home").ends_with("Home.html"));
|
||||
assert!(
|
||||
export::output_path_for(&cfg.html, "diary/2026-05-12").ends_with("diary/2026-05-12.html")
|
||||
);
|
||||
|
||||
let patterns = vec!["_drafts/**".to_string()];
|
||||
assert!(export::is_excluded(&patterns, "_drafts/secret"));
|
||||
assert!(!export::is_excluded(&patterns, "Home"));
|
||||
|
||||
// Both incremental and forced (`!`) variants are advertised.
|
||||
assert!(COMMANDS.contains(&"nuwiki.export.allToHtml"));
|
||||
assert!(COMMANDS.contains(&"nuwiki.export.allToHtmlForce"));
|
||||
}
|
||||
|
||||
// :NuwikiRss — write `rss.xml` summarising recent diary entries.
|
||||
#[test]
|
||||
fn nuwiki_rss_writes_feed_file() {
|
||||
let root = std::env::temp_dir().join(format!("nuwiki-cov-rss-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let cfg = WikiConfig::from_root(root.clone());
|
||||
|
||||
let entries = vec![
|
||||
diary::DiaryEntry {
|
||||
date: DiaryDate::from_ymd(2026, 5, 10).unwrap(),
|
||||
uri: Url::from_file_path(root.join("diary/2026-05-10.wiki")).unwrap(),
|
||||
},
|
||||
diary::DiaryEntry {
|
||||
date: DiaryDate::from_ymd(2026, 5, 12).unwrap(),
|
||||
uri: Url::from_file_path(root.join("diary/2026-05-12.wiki")).unwrap(),
|
||||
},
|
||||
];
|
||||
|
||||
let path = export_ops::write_rss(&cfg, &entries).unwrap();
|
||||
assert!(path.ends_with("rss.xml"));
|
||||
let xml = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(xml.contains("<rss version=\"2.0\">"));
|
||||
// Newest entry listed first.
|
||||
let p12 = xml.find("2026-05-12").unwrap();
|
||||
let p10 = xml.find("2026-05-10").unwrap();
|
||||
assert!(p12 < p10);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Group 6: Other
|
||||
// =====================================================================
|
||||
|
||||
// :NuwikiInstall — install/re-install the `nuwiki-ls` binary. This is an
|
||||
// editor-side action (binary download / cargo build); it is deliberately
|
||||
// NOT an LSP executeCommand, so the server must not advertise it.
|
||||
#[test]
|
||||
fn nuwiki_install_is_editor_only_not_an_lsp_command() {
|
||||
assert!(!COMMANDS.iter().any(|c| c.contains("install")));
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Command-surface cross-check: every documented command that maps to an
|
||||
// executeCommand handler is actually advertised by the server.
|
||||
// =====================================================================
|
||||
|
||||
#[test]
|
||||
fn every_documented_lsp_command_is_advertised() {
|
||||
let expected = [
|
||||
"nuwiki.wiki.openIndex", // :NuwikiIndex
|
||||
"nuwiki.wiki.tabOpenIndex", // :NuwikiTabIndex
|
||||
"nuwiki.wiki.listAll", // :NuwikiUISelect
|
||||
"nuwiki.wiki.gotoPage", // :NuwikiGoto
|
||||
"nuwiki.file.delete", // :NuwikiDeleteFile
|
||||
"nuwiki.diary.openToday", // :NuwikiMakeDiaryNote
|
||||
"nuwiki.diary.openYesterday", // :NuwikiMakeYesterdayDiaryNote
|
||||
"nuwiki.diary.openTomorrow", // :NuwikiMakeTomorrowDiaryNote
|
||||
"nuwiki.diary.openIndex", // :NuwikiDiaryIndex
|
||||
"nuwiki.diary.generateIndex", // :NuwikiDiaryGenerateLinks
|
||||
"nuwiki.diary.next", // :NuwikiDiaryNextDay
|
||||
"nuwiki.diary.prev", // :NuwikiDiaryPrevDay
|
||||
"nuwiki.toc.generate", // :NuwikiTOC
|
||||
"nuwiki.links.generate", // :NuwikiGenerateLinks
|
||||
"nuwiki.workspace.checkLinks", // :NuwikiCheckLinks
|
||||
"nuwiki.tags.search", // :NuwikiSearchTags
|
||||
"nuwiki.tags.generateLinks", // :NuwikiGenerateTagLinks
|
||||
"nuwiki.tags.rebuild", // :NuwikiRebuildTags
|
||||
"nuwiki.export.currentToHtml", // :Nuwiki2HTML
|
||||
"nuwiki.export.browse", // :Nuwiki2HTMLBrowse
|
||||
"nuwiki.export.allToHtml", // :NuwikiAll2HTML
|
||||
"nuwiki.export.rss", // :NuwikiRss
|
||||
];
|
||||
for cmd in expected {
|
||||
assert!(COMMANDS.contains(&cmd), "server must advertise {cmd}");
|
||||
}
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
//! Pure-function tests for the rename helper + the file-delete
|
||||
//! command. The async `Backend::rename` end-to-end (with `read_or_open`
|
||||
//! hitting disk) is exercised by integration tests but covered here
|
||||
//! at the building-block level — same logic, no async client.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use nuwiki_lsp::rename::{build_new_uri, rewrite_wikilink_target};
|
||||
use tower_lsp::lsp_types::Url;
|
||||
|
||||
// ===== rewrite_wikilink_target =====
|
||||
|
||||
#[test]
|
||||
fn rewrites_plain_wikilink() {
|
||||
let out = rewrite_wikilink_target("[[Old Page]]", "New Page").unwrap();
|
||||
assert_eq!(out, "[[New Page]]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_description() {
|
||||
let out = rewrite_wikilink_target("[[Old|some text]]", "New").unwrap();
|
||||
assert_eq!(out, "[[New|some text]]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_anchor() {
|
||||
let out = rewrite_wikilink_target("[[Old#section]]", "New").unwrap();
|
||||
assert_eq!(out, "[[New#section]]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_anchor_and_description() {
|
||||
let out = rewrite_wikilink_target("[[Old#sec|desc]]", "New").unwrap();
|
||||
assert_eq!(out, "[[New#sec|desc]]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewrites_subdirectory_path() {
|
||||
let out = rewrite_wikilink_target("[[old/page]]", "new/subdir/page").unwrap();
|
||||
assert_eq!(out, "[[new/subdir/page]]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_none_for_non_wikilink_text() {
|
||||
assert_eq!(rewrite_wikilink_target("just text", "X"), None);
|
||||
assert_eq!(rewrite_wikilink_target("[[not closed", "X"), None);
|
||||
assert_eq!(rewrite_wikilink_target("not opened]]", "X"), None);
|
||||
assert_eq!(rewrite_wikilink_target("[]", "X"), None);
|
||||
}
|
||||
|
||||
// ===== build_new_uri =====
|
||||
|
||||
#[test]
|
||||
fn builds_uri_under_root() {
|
||||
let uri = build_new_uri(&PathBuf::from("/tmp/wiki"), "Page", ".wiki").unwrap();
|
||||
let path = uri.to_file_path().unwrap();
|
||||
assert_eq!(path, PathBuf::from("/tmp/wiki/Page.wiki"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_uri_with_subdirectory() {
|
||||
let uri = build_new_uri(&PathBuf::from("/tmp/wiki"), "dir/Page", ".wiki").unwrap();
|
||||
let path = uri.to_file_path().unwrap();
|
||||
assert_eq!(path, PathBuf::from("/tmp/wiki/dir/Page.wiki"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omits_duplicate_extension() {
|
||||
let uri = build_new_uri(&PathBuf::from("/tmp/wiki"), "Page.wiki", ".wiki").unwrap();
|
||||
let path = uri.to_file_path().unwrap();
|
||||
assert_eq!(path, PathBuf::from("/tmp/wiki/Page.wiki"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_empty_path_segments() {
|
||||
// `//page` should not collapse into a filesystem-absolute path.
|
||||
let uri = build_new_uri(&PathBuf::from("/tmp/wiki"), "//page", ".wiki").unwrap();
|
||||
let path = uri.to_file_path().unwrap();
|
||||
assert_eq!(path, PathBuf::from("/tmp/wiki/page.wiki"));
|
||||
}
|
||||
|
||||
// ===== executeCommand: nuwiki.file.delete =====
|
||||
//
|
||||
// We don't need a Backend to drive `commands::execute` for the delete
|
||||
// command — it ignores its first arg. The smoke test verifies the
|
||||
// dispatcher + DeleteFile-op packaging.
|
||||
|
||||
#[tokio::test]
|
||||
async fn file_delete_returns_workspace_edit_with_delete_op() {
|
||||
use tower_lsp::lsp_types::{DocumentChangeOperation, DocumentChanges, ResourceOp};
|
||||
// Building a real Backend requires a Client. For this unit test we use
|
||||
// `commands::execute` with `Backend` typed as `&_` since the delete
|
||||
// path doesn't touch backend state. Instead we drive the inner logic
|
||||
// by constructing the args inline.
|
||||
//
|
||||
// Easier: shape the test as an integration test over the public
|
||||
// module surface — call `commands::execute` once we expose a thin
|
||||
// builder. We test the JSON-shaped contract using the
|
||||
// edits module directly, since that's what `file_delete` returns.
|
||||
let uri = Url::parse("file:///tmp/note.wiki").unwrap();
|
||||
let mut b = nuwiki_lsp::edits::WorkspaceEditBuilder::new();
|
||||
b.file_op(nuwiki_lsp::edits::op_delete(uri.clone()));
|
||||
let we = b.build();
|
||||
|
||||
let DocumentChanges::Operations(ops) = we.document_changes.unwrap() else {
|
||||
panic!("expected Operations variant");
|
||||
};
|
||||
assert_eq!(ops.len(), 1);
|
||||
match &ops[0] {
|
||||
DocumentChangeOperation::Op(ResourceOp::Delete(d)) => {
|
||||
assert_eq!(d.uri, uri);
|
||||
}
|
||||
other => panic!("expected DeleteFile op, got {other:?}"),
|
||||
}
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
//! Heading level rewriter — `nuwiki.heading.addLevel` /
|
||||
//! `nuwiki.heading.removeLevel`.
|
||||
|
||||
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
|
||||
use nuwiki_core::syntax::SyntaxPlugin;
|
||||
use nuwiki_lsp::commands::ops;
|
||||
use tower_lsp::lsp_types::Url;
|
||||
|
||||
fn parse(src: &str) -> nuwiki_core::ast::DocumentNode {
|
||||
VimwikiSyntax::new().parse(src)
|
||||
}
|
||||
|
||||
fn dummy_uri() -> Url {
|
||||
Url::parse("file:///tmp/note.wiki").unwrap()
|
||||
}
|
||||
|
||||
fn one_text_edit(edit: tower_lsp::lsp_types::WorkspaceEdit) -> tower_lsp::lsp_types::TextEdit {
|
||||
let changes = edit.changes.expect("expected changes map");
|
||||
let (_, edits) = changes.into_iter().next().expect("at least one uri");
|
||||
assert_eq!(edits.len(), 1, "expected exactly one text edit");
|
||||
edits.into_iter().next().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_heading_level_promotes_h2_to_h3() {
|
||||
let src = "== Sub ==\n";
|
||||
let doc = parse(src);
|
||||
let edit =
|
||||
ops::heading_change_level(src, &doc, &dummy_uri(), 0, 0, true, 1).expect("add_level edit");
|
||||
let te = one_text_edit(edit);
|
||||
assert_eq!(te.new_text, "=== Sub ===");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_heading_level_caps_at_6() {
|
||||
let src = "====== Deep ======\n";
|
||||
let doc = parse(src);
|
||||
let edit = ops::heading_change_level(src, &doc, &dummy_uri(), 0, 0, true, 1);
|
||||
// No-op because level 6 is the cap; clamp matched current level.
|
||||
assert!(edit.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_heading_level_demotes() {
|
||||
let src = "=== Mid ===\n";
|
||||
let doc = parse(src);
|
||||
let edit = ops::heading_change_level(src, &doc, &dummy_uri(), 0, 0, true, -1)
|
||||
.expect("remove_level edit");
|
||||
let te = one_text_edit(edit);
|
||||
assert_eq!(te.new_text, "== Mid ==");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_heading_level_at_h1_is_noop() {
|
||||
let src = "= Top =\n";
|
||||
let doc = parse(src);
|
||||
let edit = ops::heading_change_level(src, &doc, &dummy_uri(), 0, 0, true, -1);
|
||||
assert!(edit.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heading_change_returns_none_when_cursor_not_on_heading() {
|
||||
let src = "paragraph\n";
|
||||
let doc = parse(src);
|
||||
let edit = ops::heading_change_level(src, &doc, &dummy_uri(), 0, 0, true, 1);
|
||||
assert!(edit.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewrite_heading_with_level_keeps_leading_whitespace_outside_span() {
|
||||
// The lexer's heading span starts at the first `=`, not the line's
|
||||
// start, so the leading whitespace that marks a "centered" heading
|
||||
// lives outside the edit range — preserved automatically. The
|
||||
// rewriter's output is just the `=...=` portion.
|
||||
let src = " = Title =\n";
|
||||
let doc = parse(src);
|
||||
let h = match &doc.children[0] {
|
||||
nuwiki_core::ast::BlockNode::Heading(h) => h,
|
||||
_ => panic!("expected heading"),
|
||||
};
|
||||
let new = ops::rewrite_heading_with_level(src, h.span, 2).unwrap();
|
||||
assert_eq!(new, "== Title ==");
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
//! Link-related LSP commands.
|
||||
//!
|
||||
//! Covers:
|
||||
//! - `nuwiki.link.pasteWikilink` / `nuwiki.link.pasteUrl` — page-name
|
||||
//! synthesis from a URI and the textual shape the handler inserts
|
||||
//! at the cursor.
|
||||
//! - Wikilink resolution, including the regression where `<CR>` on a
|
||||
//! wikilink to a not-yet-created page must still resolve to a URI
|
||||
//! so the editor can open (and on save create) the future page.
|
||||
//! The resolver itself is private; we exercise the synthesised-path
|
||||
//! semantics via the public config helpers + index lookup.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use nuwiki_core::ast::LinkKind;
|
||||
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
|
||||
use nuwiki_core::syntax::SyntaxPlugin;
|
||||
use nuwiki_lsp::config::WikiConfig;
|
||||
use nuwiki_lsp::index::{page_name_from_uri, WorkspaceIndex};
|
||||
use tower_lsp::lsp_types::Url;
|
||||
|
||||
fn parse(src: &str) -> nuwiki_core::ast::DocumentNode {
|
||||
VimwikiSyntax::new().parse(src)
|
||||
}
|
||||
|
||||
// ===== paste helpers =====
|
||||
|
||||
#[test]
|
||||
fn commands_list_advertises_link_helpers() {
|
||||
let names: Vec<&str> = nuwiki_lsp::commands::COMMANDS.to_vec();
|
||||
for name in [
|
||||
"nuwiki.link.pasteWikilink",
|
||||
"nuwiki.link.pasteUrl",
|
||||
"nuwiki.link.catUrl",
|
||||
] {
|
||||
assert!(names.contains(&name), "missing: {name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_name_for_root_level_page() {
|
||||
let cfg = WikiConfig::from_root(PathBuf::from("/tmp/wiki"));
|
||||
let uri = Url::from_file_path("/tmp/wiki/Home.wiki").unwrap();
|
||||
assert_eq!(page_name_from_uri(&uri, Some(&cfg.root)), "Home");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_name_for_subdir_page() {
|
||||
let cfg = WikiConfig::from_root(PathBuf::from("/tmp/wiki"));
|
||||
let uri = Url::from_file_path("/tmp/wiki/diary/2026-05-11.wiki").unwrap();
|
||||
assert_eq!(
|
||||
page_name_from_uri(&uri, Some(&cfg.root)),
|
||||
"diary/2026-05-11"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_url_format_matches_export_output_path_for() {
|
||||
// The URL the `pasteUrl` handler emits is `<name>.html`, where
|
||||
// `<name>` walks path segments. Mirror that derivation and assert
|
||||
// the shape we'd insert at cursor.
|
||||
let name = "diary/2026-05-11";
|
||||
let mut url = String::new();
|
||||
for (i, seg) in name.split('/').enumerate() {
|
||||
if i > 0 {
|
||||
url.push('/');
|
||||
}
|
||||
url.push_str(seg);
|
||||
}
|
||||
url.push_str(".html");
|
||||
assert_eq!(url, "diary/2026-05-11.html");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_wikilink_shape_for_root_page() {
|
||||
// `[[<name>]]` is what the handler inserts at the cursor.
|
||||
let name = "Notes";
|
||||
assert_eq!(format!("[[{name}]]"), "[[Notes]]");
|
||||
}
|
||||
|
||||
// ===== resolve follow-link to non-existent page =====
|
||||
|
||||
#[test]
|
||||
fn link_to_indexed_page_resolves_via_index() {
|
||||
let root = "/tmp/follow-link-1";
|
||||
let mut idx = WorkspaceIndex::new(Some(PathBuf::from(root)));
|
||||
let target = Url::from_file_path(format!("{root}/Notes.wiki")).unwrap();
|
||||
idx.upsert(target.clone(), &parse("= notes =\n"));
|
||||
|
||||
let resolved = idx.pages_by_name.get("Notes").cloned();
|
||||
assert_eq!(resolved, Some(target));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_page_is_not_in_index() {
|
||||
let root = "/tmp/follow-link-2";
|
||||
let idx = WorkspaceIndex::new(Some(PathBuf::from(root)));
|
||||
// Empty index — "Missing" isn't there, so the fallback synthesise
|
||||
// path is the only way to follow `[[Missing]]`.
|
||||
assert!(!idx.pages_by_name.contains_key("Missing"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_root_plus_path_plus_ext_is_a_valid_uri() {
|
||||
// Sanity: the synthesise logic constructs <root>/<path>.<ext>.
|
||||
let cfg = WikiConfig::from_root(PathBuf::from("/tmp/follow-link-3"));
|
||||
assert_eq!(cfg.file_extension, ".wiki");
|
||||
let target_path = cfg.root.join("Missing.wiki");
|
||||
assert!(Url::from_file_path(&target_path).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wikilink_to_subdir_page_parses_with_slash_in_path() {
|
||||
// Synthesis must walk path segments, not just concatenate.
|
||||
let doc = parse("[[notes/Daily]]\n");
|
||||
let inline = match &doc.children[0] {
|
||||
nuwiki_core::ast::BlockNode::Paragraph(p) => &p.children[0],
|
||||
_ => panic!("expected paragraph"),
|
||||
};
|
||||
let target = match inline {
|
||||
nuwiki_core::ast::InlineNode::WikiLink(w) => &w.target,
|
||||
_ => panic!("expected wikilink"),
|
||||
};
|
||||
assert_eq!(target.kind, LinkKind::Wiki);
|
||||
assert_eq!(target.path.as_deref(), Some("notes/Daily"));
|
||||
}
|
||||
@@ -1,285 +0,0 @@
|
||||
//! Cluster A — list rewriters: `removeDone`, `renumber`,
|
||||
//! `changeSymbol`, `changeLevel`.
|
||||
|
||||
use nuwiki_core::ast::ListSymbol;
|
||||
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
|
||||
use nuwiki_core::syntax::SyntaxPlugin;
|
||||
use nuwiki_lsp::commands::ops;
|
||||
use tower_lsp::lsp_types::{Position as LspPosition, Url};
|
||||
|
||||
fn parse(src: &str) -> nuwiki_core::ast::DocumentNode {
|
||||
VimwikiSyntax::new().parse(src)
|
||||
}
|
||||
|
||||
fn one_edit_text(edit: tower_lsp::lsp_types::WorkspaceEdit) -> String {
|
||||
let changes = edit.changes.expect("changes map");
|
||||
let (_, edits) = changes.into_iter().next().expect("≥1 uri");
|
||||
edits
|
||||
.into_iter()
|
||||
.map(|e| e.new_text)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
fn uri() -> Url {
|
||||
Url::parse("file:///tmp/list.wiki").unwrap()
|
||||
}
|
||||
|
||||
// ===== parse_symbol + render_marker =====
|
||||
|
||||
#[test]
|
||||
fn parse_symbol_accepts_short_and_long_names() {
|
||||
assert_eq!(ops::parse_symbol("-"), Some(ListSymbol::Dash));
|
||||
assert_eq!(ops::parse_symbol("Dash"), Some(ListSymbol::Dash));
|
||||
assert_eq!(ops::parse_symbol("1."), Some(ListSymbol::Numeric));
|
||||
assert_eq!(ops::parse_symbol("Numeric"), Some(ListSymbol::Numeric));
|
||||
assert_eq!(ops::parse_symbol("a)"), Some(ListSymbol::AlphaParen));
|
||||
assert_eq!(ops::parse_symbol("i)"), Some(ListSymbol::RomanParen));
|
||||
assert!(ops::parse_symbol("nope").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_marker_handles_each_symbol() {
|
||||
assert_eq!(ops::render_marker(ListSymbol::Dash, 1), "-");
|
||||
assert_eq!(ops::render_marker(ListSymbol::Star, 1), "*");
|
||||
assert_eq!(ops::render_marker(ListSymbol::Hash, 1), "#");
|
||||
assert_eq!(ops::render_marker(ListSymbol::Numeric, 1), "1.");
|
||||
assert_eq!(ops::render_marker(ListSymbol::Numeric, 4), "4.");
|
||||
assert_eq!(ops::render_marker(ListSymbol::NumericParen, 7), "7)");
|
||||
assert_eq!(ops::render_marker(ListSymbol::AlphaParen, 1), "a)");
|
||||
assert_eq!(ops::render_marker(ListSymbol::AlphaParen, 26), "z)");
|
||||
assert_eq!(ops::render_marker(ListSymbol::AlphaParen, 27), "aa)");
|
||||
assert_eq!(ops::render_marker(ListSymbol::AlphaUpperParen, 2), "B)");
|
||||
assert_eq!(ops::render_marker(ListSymbol::RomanParen, 4), "iv)");
|
||||
assert_eq!(ops::render_marker(ListSymbol::RomanParen, 9), "ix)");
|
||||
assert_eq!(ops::render_marker(ListSymbol::RomanUpperParen, 14), "XIV)");
|
||||
}
|
||||
|
||||
// ===== removeDone =====
|
||||
|
||||
#[test]
|
||||
fn remove_done_strips_done_and_rejected_items() {
|
||||
let src = "- [ ] todo\n- [X] done\n- [-] rejected\n- [ ] also todo\n";
|
||||
let ast = parse(src);
|
||||
let edit = ops::remove_done_edit(src, &ast, &uri(), None, true).expect("edit");
|
||||
let changes = edit.changes.expect("changes");
|
||||
let edits = &changes[&uri()];
|
||||
assert_eq!(edits.len(), 2, "expected 2 deletions");
|
||||
// Each delete edit replaces a span with empty string.
|
||||
for e in edits {
|
||||
assert_eq!(e.new_text, "");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_done_leaves_open_items_alone() {
|
||||
let src = "- [ ] todo\n- [.] partial\n- [o] more\n";
|
||||
let ast = parse(src);
|
||||
assert!(ops::remove_done_edit(src, &ast, &uri(), None, true).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_done_returns_none_when_no_lists() {
|
||||
let src = "= heading =\nparagraph\n";
|
||||
let ast = parse(src);
|
||||
assert!(ops::remove_done_edit(src, &ast, &uri(), None, true).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_done_scoped_by_position_to_current_list() {
|
||||
// With `position` set, the scope is the contiguous list block the
|
||||
// cursor sits in — every done item in that list is removed, not just
|
||||
// the one under the cursor.
|
||||
let src = "- [X] done one\n- [ ] todo\n- [X] done two\n";
|
||||
let ast = parse(src);
|
||||
let pos = Some(LspPosition {
|
||||
line: 0,
|
||||
character: 0,
|
||||
});
|
||||
let edit = ops::remove_done_edit(src, &ast, &uri(), pos, true).expect("edit");
|
||||
let edits = &edit.changes.unwrap()[&uri()];
|
||||
assert_eq!(edits.len(), 2, "both done items in the current list match");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_done_position_leaves_other_lists_untouched() {
|
||||
// Two separate list blocks split by a paragraph. Cursor in the first
|
||||
// list removes only that list's done items; the second list's done
|
||||
// item survives.
|
||||
let src = "- [X] done a\n- [ ] todo a\n\nparagraph\n\n- [ ] todo b\n- [X] done b\n";
|
||||
let ast = parse(src);
|
||||
let pos = Some(LspPosition {
|
||||
line: 0,
|
||||
character: 0,
|
||||
});
|
||||
let edit = ops::remove_done_edit(src, &ast, &uri(), pos, true).expect("edit");
|
||||
let edits = &edit.changes.unwrap()[&uri()];
|
||||
assert_eq!(edits.len(), 1, "only the first list's done item is removed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_done_position_cascades_into_sublists() {
|
||||
// The current-list scope includes nested sublists of the block.
|
||||
let src = "- [ ] parent\n - [X] done child\n - [ ] open child\n";
|
||||
let ast = parse(src);
|
||||
let pos = Some(LspPosition {
|
||||
line: 0,
|
||||
character: 0,
|
||||
});
|
||||
let edit = ops::remove_done_edit(src, &ast, &uri(), pos, true).expect("edit");
|
||||
let edits = &edit.changes.unwrap()[&uri()];
|
||||
assert_eq!(edits.len(), 1, "the nested done child is removed");
|
||||
}
|
||||
|
||||
// ===== renumber =====
|
||||
|
||||
#[test]
|
||||
fn renumber_resequences_numeric_markers() {
|
||||
let src = "5. first\n9. second\n2. third\n";
|
||||
let ast = parse(src);
|
||||
let edit = ops::renumber_edit(src, &ast, &uri(), 0, false, true).expect("edit");
|
||||
let edits = &edit.changes.unwrap()[&uri()];
|
||||
// We expect 3 marker rewrites: 5.→1., 9.→2., 2.→3.
|
||||
assert_eq!(edits.len(), 3);
|
||||
let texts: Vec<&str> = edits.iter().map(|e| e.new_text.as_str()).collect();
|
||||
assert!(texts.contains(&"1."));
|
||||
assert!(texts.contains(&"2."));
|
||||
assert!(texts.contains(&"3."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renumber_ignores_unordered_lists() {
|
||||
let src = "- a\n- b\n- c\n";
|
||||
let ast = parse(src);
|
||||
assert!(ops::renumber_edit(src, &ast, &uri(), 0, false, true).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renumber_whole_file_walks_every_list() {
|
||||
let src = "5. one\n\n2. two\n9. three\n";
|
||||
let ast = parse(src);
|
||||
let edit = ops::renumber_edit(src, &ast, &uri(), 0, true, true).expect("edit");
|
||||
let edits = &edit.changes.unwrap()[&uri()];
|
||||
// Two separate lists — first has 1 numeric item, second has 2 →
|
||||
// 3 marker rewrites total.
|
||||
assert_eq!(edits.len(), 3);
|
||||
}
|
||||
|
||||
// ===== changeSymbol =====
|
||||
|
||||
#[test]
|
||||
fn change_symbol_rewrites_just_the_current_item() {
|
||||
let src = "- a\n- b\n- c\n";
|
||||
let ast = parse(src);
|
||||
let edit =
|
||||
ops::change_symbol_edit(src, &ast, &uri(), 1, ListSymbol::Star, false, true).expect("edit");
|
||||
let edits = &edit.changes.unwrap()[&uri()];
|
||||
assert_eq!(edits.len(), 1);
|
||||
assert_eq!(edits[0].new_text, "*");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn change_symbol_whole_list_rewrites_every_item() {
|
||||
let src = "- a\n- b\n- c\n";
|
||||
let ast = parse(src);
|
||||
let edit = ops::change_symbol_edit(src, &ast, &uri(), 1, ListSymbol::Numeric, true, true)
|
||||
.expect("edit");
|
||||
let edits = &edit.changes.unwrap()[&uri()];
|
||||
assert_eq!(edits.len(), 3);
|
||||
let texts: Vec<&str> = edits.iter().map(|e| e.new_text.as_str()).collect();
|
||||
assert!(texts.contains(&"1."));
|
||||
assert!(texts.contains(&"2."));
|
||||
assert!(texts.contains(&"3."));
|
||||
}
|
||||
|
||||
// ===== removeCheckbox =====
|
||||
|
||||
#[test]
|
||||
fn remove_checkbox_strips_single_item() {
|
||||
let src = "- [ ] task\n- [X] done\n";
|
||||
let ast = parse(src);
|
||||
let edit = ops::remove_checkbox_edit(src, &ast, &uri(), 0, false, true).expect("edit");
|
||||
let edits = &edit.changes.unwrap()[&uri()];
|
||||
assert_eq!(edits.len(), 1);
|
||||
assert_eq!(edits[0].new_text, "");
|
||||
// Removes `[ ] ` — columns 2..6 on the first line.
|
||||
assert_eq!(edits[0].range.start.line, 0);
|
||||
assert_eq!(edits[0].range.start.character, 2);
|
||||
assert_eq!(edits[0].range.end.character, 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_checkbox_whole_list_strips_every_item() {
|
||||
let src = "- [ ] a\n- [X] b\n- [-] c\n";
|
||||
let ast = parse(src);
|
||||
let edit = ops::remove_checkbox_edit(src, &ast, &uri(), 0, true, true).expect("edit");
|
||||
let edits = &edit.changes.unwrap()[&uri()];
|
||||
assert_eq!(edits.len(), 3);
|
||||
for e in edits {
|
||||
assert_eq!(e.new_text, "");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_checkbox_none_without_checkbox() {
|
||||
let src = "- plain item\n";
|
||||
let ast = parse(src);
|
||||
assert!(ops::remove_checkbox_edit(src, &ast, &uri(), 0, false, true).is_none());
|
||||
}
|
||||
|
||||
// ===== changeLevel =====
|
||||
|
||||
#[test]
|
||||
fn change_level_indents_single_item() {
|
||||
let src = "- one\n- two\n";
|
||||
let ast = parse(src);
|
||||
let edit = ops::change_level_edit(src, &ast, &uri(), 1, 1, false, true).expect("edit");
|
||||
let edits = &edit.changes.unwrap()[&uri()];
|
||||
assert_eq!(edits.len(), 1);
|
||||
// delta=1 → +2 spaces
|
||||
assert_eq!(edits[0].new_text, " ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn change_level_dedents_clamps_to_zero() {
|
||||
let src = "- root\n";
|
||||
let ast = parse(src);
|
||||
let edit = ops::change_level_edit(src, &ast, &uri(), 0, -1, false, true);
|
||||
// Already at column 0 — dedent is a no-op.
|
||||
assert!(edit.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn change_level_whole_subtree_walks_descendants() {
|
||||
let src = "- parent\n - child a\n - child b\n- sibling\n";
|
||||
let ast = parse(src);
|
||||
let edit = ops::change_level_edit(src, &ast, &uri(), 0, 1, true, true).expect("edit");
|
||||
let edits = &edit.changes.unwrap()[&uri()];
|
||||
// Parent + 2 children get indented. Sibling stays.
|
||||
assert!(edits.len() >= 3, "got {} edits", edits.len());
|
||||
}
|
||||
|
||||
// ===== COMMANDS list completeness =====
|
||||
|
||||
#[test]
|
||||
fn commands_list_includes_cluster_a() {
|
||||
let names: Vec<&str> = nuwiki_lsp::commands::COMMANDS.to_vec();
|
||||
for name in [
|
||||
"nuwiki.list.removeDone",
|
||||
"nuwiki.list.removeCheckbox",
|
||||
"nuwiki.list.renumber",
|
||||
"nuwiki.list.changeSymbol",
|
||||
"nuwiki.list.changeLevel",
|
||||
] {
|
||||
assert!(names.contains(&name), "missing: {name}");
|
||||
}
|
||||
}
|
||||
|
||||
// Smoke a one_edit_text helper so it's exercised by at least one test.
|
||||
#[test]
|
||||
fn one_edit_text_round_trip() {
|
||||
let src = "- [X] done\n";
|
||||
let ast = parse(src);
|
||||
let edit = ops::remove_done_edit(src, &ast, &uri(), None, true).unwrap();
|
||||
let _ = one_edit_text(edit);
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
//! Cluster B — table rewriters: `insert`, `align`, `moveColumn`.
|
||||
|
||||
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
|
||||
use nuwiki_core::syntax::SyntaxPlugin;
|
||||
use nuwiki_lsp::commands::ops;
|
||||
use tower_lsp::lsp_types::Url;
|
||||
|
||||
fn parse(src: &str) -> nuwiki_core::ast::DocumentNode {
|
||||
VimwikiSyntax::new().parse(src)
|
||||
}
|
||||
|
||||
fn uri() -> Url {
|
||||
Url::parse("file:///tmp/tbl.wiki").unwrap()
|
||||
}
|
||||
|
||||
fn one_text(edit: tower_lsp::lsp_types::WorkspaceEdit) -> String {
|
||||
let changes = edit.changes.expect("changes");
|
||||
let (_, edits) = changes.into_iter().next().unwrap();
|
||||
edits.into_iter().next().unwrap().new_text
|
||||
}
|
||||
|
||||
// ===== render_blank_table =====
|
||||
|
||||
#[test]
|
||||
fn blank_table_has_header_separator_and_rows() {
|
||||
let out = ops::render_blank_table(3, 2);
|
||||
let lines: Vec<&str> = out.lines().collect();
|
||||
// Header + separator + 2 data rows = 4.
|
||||
assert_eq!(lines.len(), 4);
|
||||
assert_eq!(lines[0], "| | | |");
|
||||
assert_eq!(lines[1], "|--|--|--|");
|
||||
assert_eq!(lines[2], "| | | |");
|
||||
assert_eq!(lines[3], "| | | |");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blank_table_handles_single_cell_grid() {
|
||||
let out = ops::render_blank_table(1, 1);
|
||||
assert_eq!(out, "| |\n|--|\n| |\n");
|
||||
}
|
||||
|
||||
// ===== align =====
|
||||
|
||||
#[test]
|
||||
fn align_pads_short_cells_to_widest() {
|
||||
let src = "|a|bbb|c|\n|--|--|--|\n|1|2|three|\n";
|
||||
let ast = parse(src);
|
||||
let edit = ops::table_align_edit(src, &ast, &uri(), 0, true).expect("edit");
|
||||
let body = one_text(edit);
|
||||
let lines: Vec<&str> = body.lines().collect();
|
||||
eprintln!("rendered table:");
|
||||
for (i, l) in lines.iter().enumerate() {
|
||||
eprintln!(" {i}: {l:?}");
|
||||
}
|
||||
// Column widths: max(a,1)=1 → 1, max(bbb,2)=3, max(c,three)=5.
|
||||
assert_eq!(lines[0], "| a | bbb | c |");
|
||||
// Separator row is repadded with `-` runs sized `width + 2` per
|
||||
// column (the two padding spaces of content rows).
|
||||
assert!(lines[1].starts_with("|---|"), "got: {:?}", lines[1]);
|
||||
assert_eq!(lines[2], "| 1 | 2 | three |");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn align_returns_none_when_cursor_off_table() {
|
||||
let src = "paragraph\n";
|
||||
let ast = parse(src);
|
||||
assert!(ops::table_align_edit(src, &ast, &uri(), 0, true).is_none());
|
||||
}
|
||||
|
||||
// ===== moveColumn =====
|
||||
|
||||
#[test]
|
||||
fn move_column_right_swaps_with_neighbour() {
|
||||
let src = "|a|b|c|\n|--|--|--|\n|1|2|3|\n";
|
||||
let ast = parse(src);
|
||||
// Cursor on first column → swap right.
|
||||
let edit = ops::table_move_column_edit(src, &ast, &uri(), 0, 1, 1, true).expect("edit");
|
||||
let body = one_text(edit);
|
||||
let lines: Vec<&str> = body.lines().collect();
|
||||
assert!(lines[0].contains("b") && lines[0].contains("a"));
|
||||
// b should come before a after swapping col 0 → 1.
|
||||
let line0 = lines[0];
|
||||
let b_pos = line0.find('b').unwrap();
|
||||
let a_pos = line0.find('a').unwrap();
|
||||
assert!(b_pos < a_pos, "expected b before a; got {line0:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_column_left_at_zero_is_noop() {
|
||||
let src = "|a|b|\n|--|--|\n|1|2|\n";
|
||||
let ast = parse(src);
|
||||
// Cursor in column 0; left would be -1 → returns None.
|
||||
let edit = ops::table_move_column_edit(src, &ast, &uri(), 0, 1, -1, true);
|
||||
assert!(edit.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_column_returns_none_off_table() {
|
||||
let src = "paragraph\n";
|
||||
let ast = parse(src);
|
||||
assert!(ops::table_move_column_edit(src, &ast, &uri(), 0, 0, 1, true).is_none());
|
||||
}
|
||||
|
||||
// ===== COMMANDS list =====
|
||||
|
||||
#[test]
|
||||
fn commands_list_includes_cluster_b() {
|
||||
let names: Vec<&str> = nuwiki_lsp::commands::COMMANDS.to_vec();
|
||||
for name in [
|
||||
"nuwiki.table.insert",
|
||||
"nuwiki.table.align",
|
||||
"nuwiki.table.moveColumn",
|
||||
] {
|
||||
assert!(names.contains(&name), "missing: {name}");
|
||||
}
|
||||
}
|
||||
@@ -1,305 +0,0 @@
|
||||
//! Tag-related LSP surface:
|
||||
//! - `WorkspaceIndex` per-page tag lists + reverse `tags_by_name` map,
|
||||
//! stale-tag replacement on re-upsert, and tag cleanup on `remove`.
|
||||
//! - Tag commands: `nuwiki.tags.search`, `nuwiki.tags.generateLinks`,
|
||||
//! `nuwiki.tags.rebuild` — `tag_hits` / `tag_pages_snapshot` /
|
||||
//! `build_tag_links_text` / `tag_links_edit` driven directly so we
|
||||
//! don't need a live handler.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
|
||||
use nuwiki_core::syntax::SyntaxPlugin;
|
||||
use nuwiki_lsp::commands::ops;
|
||||
use nuwiki_lsp::index::WorkspaceIndex;
|
||||
use tower_lsp::lsp_types::Url;
|
||||
|
||||
fn parse(src: &str) -> nuwiki_core::ast::DocumentNode {
|
||||
VimwikiSyntax::new().parse(src)
|
||||
}
|
||||
|
||||
fn build_index(root: &str, pages: &[(&str, &str)]) -> WorkspaceIndex {
|
||||
let mut idx = WorkspaceIndex::new(Some(PathBuf::from(root)));
|
||||
for (name, src) in pages {
|
||||
let path = format!("{root}/{name}.wiki");
|
||||
let uri = Url::from_file_path(&path).unwrap();
|
||||
idx.upsert(uri, &parse(src));
|
||||
}
|
||||
idx
|
||||
}
|
||||
|
||||
// ===== Workspace index: per-page tags + reverse map =====
|
||||
|
||||
#[test]
|
||||
fn upsert_populates_per_page_tags() {
|
||||
let mut idx = WorkspaceIndex::new(Some(PathBuf::from("/wiki")));
|
||||
let uri = Url::from_file_path("/wiki/Home.wiki").unwrap();
|
||||
idx.upsert(uri.clone(), &parse(":alpha:beta:\n= Home =\n"));
|
||||
|
||||
let page = idx.page(&uri).expect("indexed");
|
||||
assert_eq!(page.tags.len(), 2);
|
||||
let names: Vec<_> = page.tags.iter().map(|t| t.name.as_str()).collect();
|
||||
assert!(names.contains(&"alpha"));
|
||||
assert!(names.contains(&"beta"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tags_by_name_aggregates_across_pages() {
|
||||
let mut idx = WorkspaceIndex::new(Some(PathBuf::from("/wiki")));
|
||||
let home = Url::from_file_path("/wiki/Home.wiki").unwrap();
|
||||
let about = Url::from_file_path("/wiki/About.wiki").unwrap();
|
||||
idx.upsert(home.clone(), &parse(":shared:\n"));
|
||||
idx.upsert(about.clone(), &parse(":shared:other:\n"));
|
||||
|
||||
let shared = idx.tags_for("shared");
|
||||
assert_eq!(shared.len(), 2);
|
||||
let uris: Vec<&Url> = shared.iter().map(|o| &o.uri).collect();
|
||||
assert!(uris.contains(&&home));
|
||||
assert!(uris.contains(&&about));
|
||||
|
||||
assert_eq!(idx.tags_for("other").len(), 1);
|
||||
assert!(idx.tags_for("nonexistent").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn re_upsert_replaces_stale_tags() {
|
||||
let mut idx = WorkspaceIndex::new(Some(PathBuf::from("/wiki")));
|
||||
let uri = Url::from_file_path("/wiki/Home.wiki").unwrap();
|
||||
idx.upsert(uri.clone(), &parse(":old:\n"));
|
||||
assert_eq!(idx.tags_for("old").len(), 1);
|
||||
|
||||
idx.upsert(uri.clone(), &parse(":new:\n"));
|
||||
assert!(idx.tags_for("old").is_empty());
|
||||
assert_eq!(idx.tags_for("new").len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_drops_tags_and_reverse_entries() {
|
||||
let mut idx = WorkspaceIndex::new(Some(PathBuf::from("/wiki")));
|
||||
let uri = Url::from_file_path("/wiki/Home.wiki").unwrap();
|
||||
idx.upsert(uri.clone(), &parse(":a:b:\n"));
|
||||
idx.remove(&uri);
|
||||
|
||||
assert!(idx.page(&uri).is_none());
|
||||
assert!(idx.tags_for("a").is_empty());
|
||||
assert!(idx.tags_for("b").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heading_scope_tag_indexed_but_not_in_metadata() {
|
||||
// Tag at the third line below a heading is `Heading`-scoped; the
|
||||
// metadata.tags accumulator only collects file-scope tags.
|
||||
let mut idx = WorkspaceIndex::new(Some(PathBuf::from("/wiki")));
|
||||
let uri = Url::from_file_path("/wiki/Home.wiki").unwrap();
|
||||
let doc = parse("para\n\n\n= H =\n:scoped:\n");
|
||||
idx.upsert(uri.clone(), &doc);
|
||||
|
||||
assert_eq!(idx.tags_for("scoped").len(), 1);
|
||||
assert!(doc.metadata.tags.is_empty());
|
||||
}
|
||||
|
||||
// ===== `nuwiki.tags.search` — tag_hits =====
|
||||
|
||||
#[test]
|
||||
fn tag_hits_empty_query_returns_every_tag() {
|
||||
let idx = build_index(
|
||||
"/tmp/th1",
|
||||
&[
|
||||
("Home", "= Home =\n:work:personal:\n"),
|
||||
("Notes", "= Notes =\n:work:\n"),
|
||||
],
|
||||
);
|
||||
let hits = ops::tag_hits(&idx, "");
|
||||
let names: Vec<&str> = hits.iter().map(|h| h.name.as_str()).collect();
|
||||
assert!(names.contains(&"work"));
|
||||
assert!(names.contains(&"personal"));
|
||||
// 2 pages tagged with `work`, 1 with `personal` → 3 hits.
|
||||
assert_eq!(hits.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_hits_substring_filter_case_insensitive() {
|
||||
let idx = build_index(
|
||||
"/tmp/th2",
|
||||
&[("Page", "= Page =\n:release-2026:RoadMap:\n")],
|
||||
);
|
||||
let hits = ops::tag_hits(&idx, "RELEASE");
|
||||
assert_eq!(hits.len(), 1);
|
||||
assert_eq!(hits[0].name, "release-2026");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_hits_orders_by_name_then_page() {
|
||||
let idx = build_index(
|
||||
"/tmp/th3",
|
||||
&[
|
||||
("Beta", "= Beta =\n:zeta:\n"),
|
||||
("Alpha", "= Alpha =\n:alpha:\n"),
|
||||
("Gamma", "= Gamma =\n:alpha:\n"),
|
||||
],
|
||||
);
|
||||
let hits = ops::tag_hits(&idx, "");
|
||||
let pairs: Vec<(String, String)> = hits
|
||||
.iter()
|
||||
.map(|h| (h.name.clone(), h.page.clone()))
|
||||
.collect();
|
||||
// alpha tag comes first; within it, Alpha page before Gamma; then zeta/Beta.
|
||||
assert_eq!(pairs[0], ("alpha".into(), "Alpha".into()));
|
||||
assert_eq!(pairs[1], ("alpha".into(), "Gamma".into()));
|
||||
assert_eq!(pairs[2], ("zeta".into(), "Beta".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_hits_serializes_to_expected_json_shape() {
|
||||
let idx = build_index("/tmp/th4", &[("Page", "= Page =\n:release:\n")]);
|
||||
let hits = ops::tag_hits(&idx, "");
|
||||
let v = serde_json::to_value(&hits).unwrap();
|
||||
let row = &v[0];
|
||||
assert_eq!(row["name"], "release");
|
||||
assert!(row["uri"].is_string());
|
||||
assert!(row["range"]["start"]["line"].is_number());
|
||||
assert!(row["page"].is_string());
|
||||
// scope is one of the three string variants.
|
||||
let scope = row["scope"].as_str().unwrap();
|
||||
assert!(
|
||||
["file", "heading", "standalone"].contains(&scope),
|
||||
"{scope}"
|
||||
);
|
||||
}
|
||||
|
||||
// ===== `nuwiki.tags.rebuild` — tag_pages_snapshot =====
|
||||
|
||||
#[test]
|
||||
fn tag_pages_snapshot_deduplicates_pages_per_tag() {
|
||||
// Same page has the tag in both file scope and heading scope —
|
||||
// dedup so the rendered list doesn't show it twice.
|
||||
let idx = build_index(
|
||||
"/tmp/ts1",
|
||||
&[("Home", "= Home =\n:release:\n== Section ==\n:release:\n")],
|
||||
);
|
||||
let snap = ops::tag_pages_snapshot(&idx);
|
||||
assert_eq!(snap.get("release").unwrap().len(), 1);
|
||||
assert_eq!(snap["release"][0], "Home");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_pages_snapshot_sorts_pages_alphabetically() {
|
||||
let idx = build_index(
|
||||
"/tmp/ts2",
|
||||
&[
|
||||
("Charlie", "= Charlie =\n:topic:\n"),
|
||||
("Alpha", "= Alpha =\n:topic:\n"),
|
||||
("Bravo", "= Bravo =\n:topic:\n"),
|
||||
],
|
||||
);
|
||||
let snap = ops::tag_pages_snapshot(&idx);
|
||||
assert_eq!(snap["topic"], vec!["Alpha", "Bravo", "Charlie"]);
|
||||
}
|
||||
|
||||
// ===== `nuwiki.tags.generateLinks` — build_tag_links_text =====
|
||||
|
||||
#[test]
|
||||
fn build_tag_links_for_single_tag() {
|
||||
let mut snap = BTreeMap::new();
|
||||
snap.insert("release".to_string(), vec!["Alpha".into(), "Beta".into()]);
|
||||
let out = ops::build_tag_links_text(&snap, Some("release")).unwrap();
|
||||
let lines: Vec<&str> = out.lines().collect();
|
||||
assert_eq!(lines[0], "= Tag: release =");
|
||||
assert_eq!(lines[1], "- [[Alpha]]");
|
||||
assert_eq!(lines[2], "- [[Beta]]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_tag_links_for_missing_tag_returns_none() {
|
||||
let snap = BTreeMap::new();
|
||||
assert!(ops::build_tag_links_text(&snap, Some("ghost")).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_tag_links_full_index_groups_by_tag() {
|
||||
let mut snap = BTreeMap::new();
|
||||
snap.insert("a".to_string(), vec!["P1".into()]);
|
||||
snap.insert("b".to_string(), vec!["P2".into(), "P3".into()]);
|
||||
let out = ops::build_tag_links_text(&snap, None).unwrap();
|
||||
assert!(out.starts_with("= Tags =\n"));
|
||||
assert!(out.contains("== a =="));
|
||||
assert!(out.contains("== b =="));
|
||||
assert!(out.contains("- [[P1]]"));
|
||||
assert!(out.contains("- [[P2]]"));
|
||||
assert!(out.contains("- [[P3]]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_tag_links_full_index_returns_none_when_no_tags() {
|
||||
let snap = BTreeMap::new();
|
||||
assert!(ops::build_tag_links_text(&snap, None).is_none());
|
||||
}
|
||||
|
||||
// ===== `nuwiki.tags.generateLinks` — tag_links_edit (in-buffer rewrite) =====
|
||||
|
||||
#[test]
|
||||
fn tag_links_edit_inserts_when_section_missing() {
|
||||
let src = "Some content.\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).expect("got an edit");
|
||||
let te = &edit.changes.unwrap()[&uri][0];
|
||||
// Insertion at start.
|
||||
assert_eq!(te.range.start, te.range.end);
|
||||
assert!(te.new_text.contains("= Tag: release ="));
|
||||
assert!(te.new_text.contains("[[Alpha]]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_links_edit_replaces_existing_section() {
|
||||
let src = "= Tag: release =\n- [[Stale]]\n\n= Other =\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!["Fresh".into()]);
|
||||
let edit = ops::tag_links_edit(src, &ast, &uri, Some("release"), &snap, true).expect("edit");
|
||||
let te = &edit.changes.unwrap()[&uri][0];
|
||||
assert_eq!(te.range.start.line, 0);
|
||||
assert!(te.new_text.contains("[[Fresh]]"));
|
||||
assert!(!te.new_text.contains("Stale"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_links_edit_full_index_replaces_existing_tags_section() {
|
||||
let src = "= Tags =\n- [[old]]\n";
|
||||
let ast = parse(src);
|
||||
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).expect("edit");
|
||||
let te = &edit.changes.unwrap()[&uri][0];
|
||||
assert!(te.new_text.contains("== alpha =="));
|
||||
assert!(!te.new_text.contains("[[old]]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_links_edit_returns_none_for_unknown_tag() {
|
||||
let src = "hi\n";
|
||||
let ast = parse(src);
|
||||
let uri = Url::parse("file:///tmp/p.wiki").unwrap();
|
||||
let snap = BTreeMap::new();
|
||||
assert!(ops::tag_links_edit(src, &ast, &uri, Some("ghost"), &snap, true).is_none());
|
||||
}
|
||||
|
||||
// ===== smoke: COMMANDS list advertises tag commands =====
|
||||
|
||||
#[test]
|
||||
fn commands_list_includes_tag_commands() {
|
||||
let names: Vec<&str> = nuwiki_lsp::commands::COMMANDS.to_vec();
|
||||
for name in [
|
||||
"nuwiki.tags.search",
|
||||
"nuwiki.tags.generateLinks",
|
||||
"nuwiki.tags.rebuild",
|
||||
] {
|
||||
assert!(names.contains(&name), "missing: {name}");
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
//! Task navigation (`nuwiki.list.nextTask`) plus a smoke test that the
|
||||
//! `COMMANDS` registry advertises every list/heading/file handler the
|
||||
//! editor surface relies on.
|
||||
|
||||
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
|
||||
use nuwiki_core::syntax::SyntaxPlugin;
|
||||
use nuwiki_lsp::commands::ops;
|
||||
use tower_lsp::lsp_types::Url;
|
||||
|
||||
fn parse(src: &str) -> nuwiki_core::ast::DocumentNode {
|
||||
VimwikiSyntax::new().parse(src)
|
||||
}
|
||||
|
||||
fn dummy_uri() -> Url {
|
||||
Url::parse("file:///tmp/note.wiki").unwrap()
|
||||
}
|
||||
|
||||
// ===== next_task =====
|
||||
|
||||
#[test]
|
||||
fn next_task_returns_first_unfinished() {
|
||||
let src = "- [X] done\n- [ ] todo\n- [ ] later\n";
|
||||
let doc = parse(src);
|
||||
let loc = ops::next_task(&doc, &dummy_uri(), src, 0, 0, true).expect("found a task");
|
||||
// Should be line 1 (the first [ ]).
|
||||
assert_eq!(loc.range.start.line, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_task_wraps_around_when_no_task_after_cursor() {
|
||||
// Only task is on line 0; cursor on line 2 → wraps back.
|
||||
let src = "- [ ] todo\n- [X] done\n- [X] also done\n";
|
||||
let doc = parse(src);
|
||||
let loc = ops::next_task(&doc, &dummy_uri(), src, 2, 0, true).expect("found");
|
||||
assert_eq!(loc.range.start.line, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_task_returns_none_when_no_open_tasks() {
|
||||
let src = "- [X] a\n- [X] b\n";
|
||||
let doc = parse(src);
|
||||
let loc = ops::next_task(&doc, &dummy_uri(), src, 0, 0, true);
|
||||
assert!(loc.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_task_only_counts_items_with_checkboxes() {
|
||||
// Plain items without a `[…]` are ignored — they're not tasks.
|
||||
let src = "- plain\n- [ ] real task\n";
|
||||
let doc = parse(src);
|
||||
let loc = ops::next_task(&doc, &dummy_uri(), src, 0, 0, true).expect("found");
|
||||
assert_eq!(loc.range.start.line, 1);
|
||||
}
|
||||
|
||||
// ===== smoke: COMMANDS list matches registered handlers =====
|
||||
|
||||
#[test]
|
||||
fn commands_list_is_complete() {
|
||||
let names: Vec<&str> = nuwiki_lsp::commands::COMMANDS.to_vec();
|
||||
for name in [
|
||||
"nuwiki.file.delete",
|
||||
"nuwiki.list.toggleCheckbox",
|
||||
"nuwiki.list.cycleCheckbox",
|
||||
"nuwiki.list.rejectCheckbox",
|
||||
"nuwiki.list.nextTask",
|
||||
"nuwiki.heading.addLevel",
|
||||
"nuwiki.heading.removeLevel",
|
||||
] {
|
||||
assert!(names.contains(&name), "missing: {name}");
|
||||
}
|
||||
}
|
||||
@@ -1,659 +0,0 @@
|
||||
//! Diary feature tests:
|
||||
//! - Date primitives, path conventions, nav helpers, IndexedPage diary
|
||||
//! classification.
|
||||
//! - Frequency wiring (daily/weekly/monthly/yearly): per-frequency
|
||||
//! path/URI shape, index recognition, and `next_period` / `prev_period`
|
||||
//! navigation across mixed-frequency entries.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use nuwiki_core::date::{DiaryDate, DiaryFrequency, DiaryPeriod};
|
||||
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
|
||||
use nuwiki_core::syntax::SyntaxPlugin;
|
||||
use nuwiki_lsp::config::WikiConfig;
|
||||
use nuwiki_lsp::diary;
|
||||
use nuwiki_lsp::index::{diary_period_for_uri, WorkspaceIndex};
|
||||
use tower_lsp::lsp_types::Url;
|
||||
|
||||
fn parse(src: &str) -> nuwiki_core::ast::DocumentNode {
|
||||
VimwikiSyntax::new().parse(src)
|
||||
}
|
||||
|
||||
fn wiki_cfg(root: &str) -> WikiConfig {
|
||||
let mut cfg = WikiConfig::from_root(PathBuf::from(root));
|
||||
cfg.diary_rel_path = "diary".into();
|
||||
cfg.diary_index = "diary".into();
|
||||
cfg
|
||||
}
|
||||
|
||||
fn build_index_with_entries(root: &str, dates: &[&str]) -> WorkspaceIndex {
|
||||
let mut idx =
|
||||
WorkspaceIndex::new(Some(PathBuf::from(root))).with_diary_rel_path(Some("diary".into()));
|
||||
for d in dates {
|
||||
let path = format!("{root}/diary/{d}.wiki");
|
||||
let uri = Url::from_file_path(&path).unwrap();
|
||||
idx.upsert(uri, &parse("= entry =\n"));
|
||||
}
|
||||
idx
|
||||
}
|
||||
|
||||
// ===== DiaryDate parser =====
|
||||
|
||||
#[test]
|
||||
fn parse_accepts_canonical_iso8601() {
|
||||
let d = DiaryDate::parse("2026-05-11").unwrap();
|
||||
assert_eq!(d.year, 2026);
|
||||
assert_eq!(d.month, 5);
|
||||
assert_eq!(d.day, 11);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rejects_missing_zero_padding() {
|
||||
assert!(DiaryDate::parse("2026-5-11").is_none());
|
||||
assert!(DiaryDate::parse("2026-05-1").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rejects_alternate_separators() {
|
||||
assert!(DiaryDate::parse("2026/05/11").is_none());
|
||||
assert!(DiaryDate::parse("2026.05.11").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rejects_extra_whitespace() {
|
||||
assert!(DiaryDate::parse(" 2026-05-11").is_none());
|
||||
assert!(DiaryDate::parse("2026-05-11 ").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rejects_invalid_calendar_dates() {
|
||||
assert!(DiaryDate::parse("2026-13-01").is_none(), "month 13");
|
||||
assert!(DiaryDate::parse("2026-02-30").is_none(), "feb 30");
|
||||
assert!(DiaryDate::parse("2026-04-31").is_none(), "april 31");
|
||||
assert!(DiaryDate::parse("2026-00-10").is_none(), "month 0");
|
||||
assert!(DiaryDate::parse("2026-05-00").is_none(), "day 0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_accepts_leap_day_in_leap_year() {
|
||||
assert!(DiaryDate::parse("2024-02-29").is_some(), "2024 is leap");
|
||||
assert!(DiaryDate::parse("2023-02-29").is_none(), "2023 not leap");
|
||||
assert!(DiaryDate::parse("2000-02-29").is_some(), "div by 400");
|
||||
assert!(
|
||||
DiaryDate::parse("1900-02-29").is_none(),
|
||||
"div by 100 not 400"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_round_trip() {
|
||||
let d = DiaryDate::from_ymd(2026, 5, 11).unwrap();
|
||||
assert_eq!(d.format(), "2026-05-11");
|
||||
assert_eq!(DiaryDate::parse(&d.format()), Some(d));
|
||||
}
|
||||
|
||||
// ===== Date arithmetic =====
|
||||
|
||||
#[test]
|
||||
fn next_day_wraps_month_boundary() {
|
||||
let last_of_jan = DiaryDate::from_ymd(2026, 1, 31).unwrap();
|
||||
assert_eq!(
|
||||
last_of_jan.next_day(),
|
||||
DiaryDate::from_ymd(2026, 2, 1).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_day_wraps_year_boundary() {
|
||||
let nye = DiaryDate::from_ymd(2025, 12, 31).unwrap();
|
||||
assert_eq!(nye.next_day(), DiaryDate::from_ymd(2026, 1, 1).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prev_day_walks_back_across_month() {
|
||||
let first_of_mar = DiaryDate::from_ymd(2026, 3, 1).unwrap();
|
||||
assert_eq!(
|
||||
first_of_mar.prev_day(),
|
||||
DiaryDate::from_ymd(2026, 2, 28).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prev_day_walks_back_across_leap() {
|
||||
let first_of_mar = DiaryDate::from_ymd(2024, 3, 1).unwrap();
|
||||
assert_eq!(
|
||||
first_of_mar.prev_day(),
|
||||
DiaryDate::from_ymd(2024, 2, 29).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordering_is_chronological() {
|
||||
let a = DiaryDate::from_ymd(2026, 1, 1).unwrap();
|
||||
let b = DiaryDate::from_ymd(2026, 1, 2).unwrap();
|
||||
let c = DiaryDate::from_ymd(2026, 2, 1).unwrap();
|
||||
let d = DiaryDate::from_ymd(2027, 1, 1).unwrap();
|
||||
assert!(a < b);
|
||||
assert!(b < c);
|
||||
assert!(c < d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn today_utc_is_a_real_calendar_date() {
|
||||
let t = DiaryDate::today_utc();
|
||||
assert!(t.year >= 2020 && t.year <= 2100);
|
||||
assert!((1..=12).contains(&t.month));
|
||||
assert!((1..=31).contains(&t.day));
|
||||
}
|
||||
|
||||
// ===== WikiConfig diary paths =====
|
||||
|
||||
#[test]
|
||||
fn diary_dir_joins_root_and_rel_path() {
|
||||
let cfg = wiki_cfg("/tmp/wiki");
|
||||
assert_eq!(cfg.diary_dir(), PathBuf::from("/tmp/wiki/diary"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diary_index_path_uses_diary_index_filename_and_extension() {
|
||||
let cfg = wiki_cfg("/tmp/wiki");
|
||||
assert_eq!(
|
||||
cfg.diary_index_path(),
|
||||
PathBuf::from("/tmp/wiki/diary/diary.wiki")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diary_path_for_uses_iso_date_as_stem() {
|
||||
let cfg = wiki_cfg("/tmp/wiki");
|
||||
let date = DiaryDate::from_ymd(2026, 5, 11).unwrap();
|
||||
assert_eq!(
|
||||
cfg.diary_path_for(&date),
|
||||
PathBuf::from("/tmp/wiki/diary/2026-05-11.wiki")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diary_uri_for_date_is_file_url() {
|
||||
let cfg = wiki_cfg("/tmp/wiki");
|
||||
let date = DiaryDate::from_ymd(2026, 5, 11).unwrap();
|
||||
let uri = diary::uri_for_date(&cfg, &date).unwrap();
|
||||
assert!(uri.as_str().ends_with("/diary/2026-05-11.wiki"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diary_index_uri_resolves() {
|
||||
let cfg = wiki_cfg("/tmp/wiki");
|
||||
let uri = diary::index_uri(&cfg).unwrap();
|
||||
assert!(uri.as_str().ends_with("/diary/diary.wiki"));
|
||||
}
|
||||
|
||||
// ===== Index integration: diary_date on IndexedPage =====
|
||||
|
||||
#[test]
|
||||
fn upsert_marks_diary_entries() {
|
||||
let root = "/tmp/diary1";
|
||||
let idx = build_index_with_entries(root, &["2026-05-11"]);
|
||||
let uri = Url::from_file_path(format!("{root}/diary/2026-05-11.wiki")).unwrap();
|
||||
let page = idx.page(&uri).expect("page");
|
||||
let date = page.diary_date.expect("diary_date");
|
||||
assert_eq!(date.format(), "2026-05-11");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upsert_does_not_mark_non_diary_files() {
|
||||
let mut idx = WorkspaceIndex::new(Some(PathBuf::from("/tmp/diary2")))
|
||||
.with_diary_rel_path(Some("diary".into()));
|
||||
let uri = Url::from_file_path("/tmp/diary2/Home.wiki").unwrap();
|
||||
idx.upsert(uri.clone(), &parse("= Home =\n"));
|
||||
assert!(idx.page(&uri).unwrap().diary_date.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upsert_does_not_mark_dates_outside_diary_subdir() {
|
||||
// A file at root named like a date but not under diary/ → not a
|
||||
// diary entry.
|
||||
let mut idx = WorkspaceIndex::new(Some(PathBuf::from("/tmp/diary3")))
|
||||
.with_diary_rel_path(Some("diary".into()));
|
||||
let uri = Url::from_file_path("/tmp/diary3/2026-05-11.wiki").unwrap();
|
||||
idx.upsert(uri.clone(), &parse("= note =\n"));
|
||||
assert!(idx.page(&uri).unwrap().diary_date.is_none());
|
||||
}
|
||||
|
||||
// ===== Diary listing + navigation =====
|
||||
|
||||
#[test]
|
||||
fn list_entries_returns_sorted_ascending() {
|
||||
let idx = build_index_with_entries("/tmp/diary4", &["2026-05-11", "2026-04-30", "2026-05-12"]);
|
||||
let entries = diary::list_entries(&idx);
|
||||
let dates: Vec<String> = entries.iter().map(|e| e.date.format()).collect();
|
||||
assert_eq!(dates, vec!["2026-04-30", "2026-05-11", "2026-05-12"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_entries_filtered_by_year_and_month() {
|
||||
let idx = build_index_with_entries(
|
||||
"/tmp/diary5",
|
||||
&["2025-12-31", "2026-01-01", "2026-05-11", "2026-05-12"],
|
||||
);
|
||||
let may = diary::list_entries_filtered(&idx, Some(2026), Some(5));
|
||||
assert_eq!(may.len(), 2);
|
||||
let y_only = diary::list_entries_filtered(&idx, Some(2026), None);
|
||||
assert_eq!(y_only.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_entry_finds_strictly_greater() {
|
||||
let idx = build_index_with_entries("/tmp/diary6", &["2026-05-10", "2026-05-12", "2026-05-15"]);
|
||||
let from = DiaryDate::from_ymd(2026, 5, 11).unwrap();
|
||||
let next = diary::next_entry(&idx, &from).unwrap();
|
||||
assert_eq!(next.date.format(), "2026-05-12");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_entry_none_when_no_future_entries() {
|
||||
let idx = build_index_with_entries("/tmp/diary7", &["2026-05-10"]);
|
||||
let from = DiaryDate::from_ymd(2026, 5, 11).unwrap();
|
||||
assert!(diary::next_entry(&idx, &from).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prev_entry_finds_strictly_lesser() {
|
||||
let idx = build_index_with_entries("/tmp/diary8", &["2026-05-10", "2026-05-12", "2026-05-15"]);
|
||||
let from = DiaryDate::from_ymd(2026, 5, 13).unwrap();
|
||||
let prev = diary::prev_entry(&idx, &from).unwrap();
|
||||
assert_eq!(prev.date.format(), "2026-05-12");
|
||||
}
|
||||
|
||||
// ===== Index body generation =====
|
||||
|
||||
#[test]
|
||||
fn build_index_body_is_newest_first_grouped_by_year_month() {
|
||||
let idx = build_index_with_entries(
|
||||
"/tmp/diary9",
|
||||
&["2025-12-31", "2026-05-11", "2026-05-12", "2026-04-30"],
|
||||
);
|
||||
let entries = diary::list_entries(&idx);
|
||||
let body = diary::build_index_body(&entries, "diary", "Diary", diary::DiarySort::Desc, 1);
|
||||
let lines: Vec<&str> = body.lines().collect();
|
||||
assert_eq!(lines[0], "= Diary =");
|
||||
// First date in output should be the latest: 2026-05-12
|
||||
let earliest_2026_pos = body.find("2026-05-12").unwrap();
|
||||
let latest_2025_pos = body.find("2025-12-31").unwrap();
|
||||
assert!(earliest_2026_pos < latest_2025_pos, "newer entries first");
|
||||
// Year headings appear
|
||||
assert!(body.contains("== 2026 =="));
|
||||
assert!(body.contains("== 2025 =="));
|
||||
// Month headings appear
|
||||
assert!(body.contains("=== May ==="));
|
||||
assert!(body.contains("=== December ==="));
|
||||
assert!(body.contains("=== April ==="));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_index_body_empty_when_no_entries() {
|
||||
let body = diary::build_index_body(&[], "diary", "Diary", diary::DiarySort::Desc, 1);
|
||||
assert_eq!(body, "= Diary =\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_index_body_ascending_sort_lists_oldest_first() {
|
||||
let idx = build_index_with_entries("/tmp/diarySort", &["2026-05-11", "2026-05-12"]);
|
||||
let entries = diary::list_entries(&idx);
|
||||
let body = diary::build_index_body(&entries, "diary", "Diary", diary::DiarySort::Asc, 1);
|
||||
let older = body.find("2026-05-11").unwrap();
|
||||
let newer = body.find("2026-05-12").unwrap();
|
||||
assert!(older < newer, "ascending sort lists oldest first");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_index_body_caption_level_shifts_all_headings() {
|
||||
let idx = build_index_with_entries("/tmp/diaryCap", &["2026-05-11"]);
|
||||
let entries = diary::list_entries(&idx);
|
||||
let body = diary::build_index_body(&entries, "diary", "Diary", diary::DiarySort::Desc, 2);
|
||||
assert!(body.contains("== Diary =="), "caption at level 2");
|
||||
assert!(
|
||||
body.contains("=== 2026 ==="),
|
||||
"year nests one below caption"
|
||||
);
|
||||
assert!(
|
||||
body.contains("==== May ===="),
|
||||
"month nests two below caption"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_index_body_clamps_caption_level_to_six() {
|
||||
let idx = build_index_with_entries("/tmp/diaryClamp", &["2026-05-11"]);
|
||||
let entries = diary::list_entries(&idx);
|
||||
// caption_level 6 → year/month would be 7/8 but clamp to 6.
|
||||
let body = diary::build_index_body(&entries, "diary", "Diary", diary::DiarySort::Desc, 6);
|
||||
assert!(body.contains("====== Diary ======"));
|
||||
assert!(!body.contains("======="), "no heading exceeds level 6");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_index_body_round_trip() {
|
||||
let cfg = wiki_cfg("/tmp/diaryA");
|
||||
let idx = build_index_with_entries("/tmp/diaryA", &["2026-05-11"]);
|
||||
let body = diary::render_index_body(&cfg, &idx);
|
||||
assert!(body.contains("= Diary ="));
|
||||
assert!(body.contains("[[diary/2026-05-11]]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_index_body_honours_custom_header_sort_and_caption() {
|
||||
let mut cfg = wiki_cfg("/tmp/diaryCustom");
|
||||
cfg.diary_header = "Journal".into();
|
||||
cfg.diary_sort = "asc".into();
|
||||
cfg.diary_caption_level = 2;
|
||||
let idx = build_index_with_entries("/tmp/diaryCustom", &["2026-05-11", "2026-05-12"]);
|
||||
let body = diary::render_index_body(&cfg, &idx);
|
||||
assert!(
|
||||
body.contains("== Journal =="),
|
||||
"custom header at caption level 2"
|
||||
);
|
||||
let older = body.find("2026-05-11").unwrap();
|
||||
let newer = body.find("2026-05-12").unwrap();
|
||||
assert!(older < newer, "asc sort honoured");
|
||||
}
|
||||
|
||||
// ===== is_in_diary =====
|
||||
|
||||
#[test]
|
||||
fn is_in_diary_recognises_subdir_paths() {
|
||||
let cfg = wiki_cfg("/tmp/wiki");
|
||||
assert!(diary::is_in_diary(
|
||||
&cfg,
|
||||
&PathBuf::from("/tmp/wiki/diary/2026-05-11.wiki")
|
||||
));
|
||||
assert!(!diary::is_in_diary(
|
||||
&cfg,
|
||||
&PathBuf::from("/tmp/wiki/Home.wiki")
|
||||
));
|
||||
}
|
||||
|
||||
// ===== Serde =====
|
||||
|
||||
#[test]
|
||||
fn diary_entry_serializes_to_date_string_and_uri() {
|
||||
let uri = Url::parse("file:///tmp/wiki/diary/2026-05-11.wiki").unwrap();
|
||||
let entry = diary::DiaryEntry {
|
||||
date: DiaryDate::from_ymd(2026, 5, 11).unwrap(),
|
||||
uri,
|
||||
};
|
||||
let v = serde_json::to_value(&entry).unwrap();
|
||||
assert_eq!(v["date"], "2026-05-11");
|
||||
assert!(v["uri"].as_str().unwrap().contains("2026-05-11.wiki"));
|
||||
}
|
||||
|
||||
// ===== COMMANDS list completeness =====
|
||||
|
||||
#[test]
|
||||
fn commands_list_includes_diary_entries() {
|
||||
let names: Vec<&str> = nuwiki_lsp::commands::COMMANDS.to_vec();
|
||||
for name in [
|
||||
"nuwiki.diary.openToday",
|
||||
"nuwiki.diary.openYesterday",
|
||||
"nuwiki.diary.openTomorrow",
|
||||
"nuwiki.diary.openIndex",
|
||||
"nuwiki.diary.generateIndex",
|
||||
"nuwiki.diary.next",
|
||||
"nuwiki.diary.prev",
|
||||
"nuwiki.diary.listEntries",
|
||||
"nuwiki.diary.openForDate",
|
||||
] {
|
||||
assert!(names.contains(&name), "missing: {name}");
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Config wire format =====
|
||||
|
||||
#[test]
|
||||
fn wiki_config_defaults_match_vimwiki() {
|
||||
let cfg = WikiConfig::empty();
|
||||
assert_eq!(cfg.diary_rel_path, "diary");
|
||||
assert_eq!(cfg.diary_index, "diary");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wiki_config_from_root_uses_default_diary_paths() {
|
||||
let cfg = WikiConfig::from_root(PathBuf::from("/tmp/x"));
|
||||
assert_eq!(cfg.diary_rel_path, "diary");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_from_json_accepts_custom_diary_paths() {
|
||||
use nuwiki_lsp::config::config_from_json;
|
||||
let cfg = config_from_json(serde_json::json!({
|
||||
"wikis": [
|
||||
{ "root": "/tmp/p", "diary_rel_path": "journal", "diary_index": "index" },
|
||||
],
|
||||
}));
|
||||
assert_eq!(cfg.wikis[0].diary_rel_path, "journal");
|
||||
assert_eq!(cfg.wikis[0].diary_index, "index");
|
||||
}
|
||||
|
||||
// ===== Frequency wiring (daily / weekly / monthly / yearly) =====
|
||||
|
||||
fn cfg(root: &str, frequency: &str) -> WikiConfig {
|
||||
let mut c = WikiConfig::from_root(PathBuf::from(root));
|
||||
c.diary_rel_path = "diary".into();
|
||||
c.diary_frequency = frequency.into();
|
||||
c
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frequency_helper_picks_up_config_value() {
|
||||
assert_eq!(cfg("/tmp", "daily").frequency(), DiaryFrequency::Daily);
|
||||
assert_eq!(cfg("/tmp", "weekly").frequency(), DiaryFrequency::Weekly);
|
||||
assert_eq!(cfg("/tmp", "monthly").frequency(), DiaryFrequency::Monthly);
|
||||
assert_eq!(cfg("/tmp", "yearly").frequency(), DiaryFrequency::Yearly);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diary_path_for_period_picks_right_stem_per_frequency() {
|
||||
let c = cfg("/wiki", "daily");
|
||||
let day = DiaryPeriod::Day(DiaryDate::from_ymd(2026, 5, 12).unwrap());
|
||||
let wk = DiaryPeriod::Week {
|
||||
iso_year: 2026,
|
||||
iso_week: 19,
|
||||
};
|
||||
let mo = DiaryPeriod::Month {
|
||||
year: 2026,
|
||||
month: 5,
|
||||
};
|
||||
let yr = DiaryPeriod::Year { year: 2026 };
|
||||
assert_eq!(
|
||||
c.diary_path_for_period(&day),
|
||||
PathBuf::from("/wiki/diary/2026-05-12.wiki")
|
||||
);
|
||||
assert_eq!(
|
||||
c.diary_path_for_period(&wk),
|
||||
PathBuf::from("/wiki/diary/2026-W19.wiki")
|
||||
);
|
||||
assert_eq!(
|
||||
c.diary_path_for_period(&mo),
|
||||
PathBuf::from("/wiki/diary/2026-05.wiki")
|
||||
);
|
||||
assert_eq!(
|
||||
c.diary_path_for_period(&yr),
|
||||
PathBuf::from("/wiki/diary/2026.wiki")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uri_for_period_produces_file_url_per_frequency() {
|
||||
let c = cfg("/wiki", "weekly");
|
||||
let wk = DiaryPeriod::Week {
|
||||
iso_year: 2026,
|
||||
iso_week: 19,
|
||||
};
|
||||
let u = diary::uri_for_period(&c, &wk).unwrap();
|
||||
assert!(
|
||||
u.as_str().ends_with("/wiki/diary/2026-W19.wiki"),
|
||||
"got: {u}"
|
||||
);
|
||||
}
|
||||
|
||||
fn make_index_with(entries: &[&str]) -> WorkspaceIndex {
|
||||
let root = PathBuf::from("/wiki");
|
||||
let mut idx = WorkspaceIndex::new(Some(root.clone())).with_diary_rel_path(Some("diary".into()));
|
||||
for stem in entries {
|
||||
let path = format!("/wiki/diary/{stem}.wiki");
|
||||
let uri = Url::from_file_path(&path).unwrap();
|
||||
let ast = VimwikiSyntax::new().parse("= entry =\n");
|
||||
idx.upsert(uri, &ast);
|
||||
}
|
||||
idx
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn index_recognises_all_four_frequency_stems() {
|
||||
let idx = make_index_with(&["2026-05-12", "2026-W19", "2026-05", "2026"]);
|
||||
for (stem, expected) in [
|
||||
(
|
||||
"2026-05-12",
|
||||
DiaryPeriod::Day(DiaryDate::from_ymd(2026, 5, 12).unwrap()),
|
||||
),
|
||||
(
|
||||
"2026-W19",
|
||||
DiaryPeriod::Week {
|
||||
iso_year: 2026,
|
||||
iso_week: 19,
|
||||
},
|
||||
),
|
||||
(
|
||||
"2026-05",
|
||||
DiaryPeriod::Month {
|
||||
year: 2026,
|
||||
month: 5,
|
||||
},
|
||||
),
|
||||
("2026", DiaryPeriod::Year { year: 2026 }),
|
||||
] {
|
||||
let uri = Url::from_file_path(format!("/wiki/diary/{stem}.wiki")).unwrap();
|
||||
let page = idx.page(&uri).expect(stem);
|
||||
assert_eq!(
|
||||
page.diary_period,
|
||||
Some(expected),
|
||||
"stem {stem} should index as {expected:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn index_only_sets_diary_date_for_daily_entries() {
|
||||
let idx = make_index_with(&["2026-05-12", "2026-W19"]);
|
||||
let daily = Url::from_file_path("/wiki/diary/2026-05-12.wiki").unwrap();
|
||||
let weekly = Url::from_file_path("/wiki/diary/2026-W19.wiki").unwrap();
|
||||
assert!(idx.page(&daily).unwrap().diary_date.is_some());
|
||||
assert!(idx.page(&weekly).unwrap().diary_date.is_none());
|
||||
assert!(idx.page(&weekly).unwrap().diary_period.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diary_period_for_uri_rejects_paths_outside_diary_dir() {
|
||||
// Non-diary path with a date-looking stem — should NOT count.
|
||||
let uri = Url::from_file_path("/wiki/notes/2026-05-12.wiki").unwrap();
|
||||
let root = PathBuf::from("/wiki");
|
||||
assert!(diary_period_for_uri(&uri, Some(&root), Some("diary")).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_prev_period_navigate_at_the_same_frequency() {
|
||||
// Mix entries at different frequencies. Navigation should stay
|
||||
// within the same flavour as the pivot.
|
||||
let idx = make_index_with(&[
|
||||
"2026-W18",
|
||||
"2026-W19",
|
||||
"2026-W20", // weekly
|
||||
"2026-04",
|
||||
"2026-05",
|
||||
"2026-06", // monthly
|
||||
"2026-05-10",
|
||||
"2026-05-12", // daily
|
||||
]);
|
||||
let pivot_w = DiaryPeriod::Week {
|
||||
iso_year: 2026,
|
||||
iso_week: 19,
|
||||
};
|
||||
let next_w = diary::next_period(&idx, &pivot_w).unwrap();
|
||||
assert_eq!(
|
||||
next_w.period,
|
||||
DiaryPeriod::Week {
|
||||
iso_year: 2026,
|
||||
iso_week: 20
|
||||
}
|
||||
);
|
||||
let prev_w = diary::prev_period(&idx, &pivot_w).unwrap();
|
||||
assert_eq!(
|
||||
prev_w.period,
|
||||
DiaryPeriod::Week {
|
||||
iso_year: 2026,
|
||||
iso_week: 18
|
||||
}
|
||||
);
|
||||
|
||||
let pivot_m = DiaryPeriod::Month {
|
||||
year: 2026,
|
||||
month: 5,
|
||||
};
|
||||
let next_m = diary::next_period(&idx, &pivot_m).unwrap();
|
||||
assert_eq!(
|
||||
next_m.period,
|
||||
DiaryPeriod::Month {
|
||||
year: 2026,
|
||||
month: 6
|
||||
}
|
||||
);
|
||||
let prev_m = diary::prev_period(&idx, &pivot_m).unwrap();
|
||||
assert_eq!(
|
||||
prev_m.period,
|
||||
DiaryPeriod::Month {
|
||||
year: 2026,
|
||||
month: 4
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_prev_period_returns_none_past_the_edge() {
|
||||
let idx = make_index_with(&["2026-W19"]);
|
||||
let only = DiaryPeriod::Week {
|
||||
iso_year: 2026,
|
||||
iso_week: 19,
|
||||
};
|
||||
assert!(diary::next_period(&idx, &only).is_none());
|
||||
assert!(diary::prev_period(&idx, &only).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diary_period_for_uri_handles_each_frequency_under_diary_dir() {
|
||||
let root = PathBuf::from("/wiki");
|
||||
for (stem, expected) in [
|
||||
(
|
||||
"2026-05-12",
|
||||
DiaryPeriod::Day(DiaryDate::from_ymd(2026, 5, 12).unwrap()),
|
||||
),
|
||||
(
|
||||
"2026-W19",
|
||||
DiaryPeriod::Week {
|
||||
iso_year: 2026,
|
||||
iso_week: 19,
|
||||
},
|
||||
),
|
||||
(
|
||||
"2026-05",
|
||||
DiaryPeriod::Month {
|
||||
year: 2026,
|
||||
month: 5,
|
||||
},
|
||||
),
|
||||
("2026", DiaryPeriod::Year { year: 2026 }),
|
||||
] {
|
||||
let uri = Url::from_file_path(format!("/wiki/diary/{stem}.wiki")).unwrap();
|
||||
let got = diary_period_for_uri(&uri, Some(&root), Some("diary"));
|
||||
assert_eq!(got, Some(expected), "stem {stem}");
|
||||
}
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
//! LSP folding-range provider.
|
||||
//!
|
||||
//! Drives `folding::folding_ranges` directly. The handler wiring is
|
||||
//! shallow — it just calls into this function — so we verify the
|
||||
//! shapes here and trust the integration via the LSP test harness in
|
||||
//! `lsp_helpers.rs`.
|
||||
|
||||
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
|
||||
use nuwiki_core::syntax::SyntaxPlugin;
|
||||
use nuwiki_lsp::folding;
|
||||
|
||||
fn parse(src: &str) -> nuwiki_core::ast::DocumentNode {
|
||||
VimwikiSyntax::new().parse(src)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_headings_no_lists_emits_no_folds() {
|
||||
let src = "just text\non two lines\n";
|
||||
let ast = parse(src);
|
||||
let folds = folding::folding_ranges(&ast, folding::line_count(src));
|
||||
assert!(folds.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_heading_folds_to_end_of_document() {
|
||||
let src = "= Title =\nbody line 1\nbody line 2\n";
|
||||
let ast = parse(src);
|
||||
let folds = folding::folding_ranges(&ast, folding::line_count(src));
|
||||
assert_eq!(folds.len(), 1);
|
||||
assert_eq!(folds[0].start_line, 0);
|
||||
// body line 2 is line 2; trailing newline pushes line_count to 4 so
|
||||
// last_line == 3 (the empty line after the final newline).
|
||||
assert!(folds[0].end_line >= 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sibling_headings_each_get_their_own_fold() {
|
||||
let src = "= One =\nbody\n= Two =\nbody2\n= Three =\n";
|
||||
let ast = parse(src);
|
||||
let folds = folding::folding_ranges(&ast, folding::line_count(src));
|
||||
let heading_folds: Vec<_> = folds
|
||||
.iter()
|
||||
.filter(|f| f.start_line == 0 || f.start_line == 2 || f.start_line == 4)
|
||||
.collect();
|
||||
assert_eq!(heading_folds.len(), 3, "got: {folds:?}");
|
||||
// One closes before Two
|
||||
assert_eq!(heading_folds[0].end_line, 1);
|
||||
// Two closes before Three
|
||||
assert_eq!(heading_folds[1].end_line, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_heading_folds_inside_parent() {
|
||||
let src = "= Outer =\nintro\n== Inner ==\nbody\n= Other =\n";
|
||||
let ast = parse(src);
|
||||
let folds = folding::folding_ranges(&ast, folding::line_count(src));
|
||||
// Outer fold spans up to the line before `= Other =` (line 4).
|
||||
let outer = folds
|
||||
.iter()
|
||||
.find(|f| f.start_line == 0)
|
||||
.expect("outer fold");
|
||||
assert_eq!(outer.end_line, 3);
|
||||
// Inner fold is bounded by the next h1 too (since the only following
|
||||
// heading is h1, which has level <= 2).
|
||||
let inner = folds
|
||||
.iter()
|
||||
.find(|f| f.start_line == 2)
|
||||
.expect("inner fold");
|
||||
assert_eq!(inner.end_line, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn top_level_list_gets_its_own_fold() {
|
||||
let src = "before\n- item one\n- item two\n- item three\nafter\n";
|
||||
let ast = parse(src);
|
||||
let folds = folding::folding_ranges(&ast, folding::line_count(src));
|
||||
let list_fold = folds.iter().find(|f| f.start_line == 1);
|
||||
assert!(list_fold.is_some(), "expected a list fold: {folds:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_line_blocks_are_not_folded() {
|
||||
let src = "= Solo =\n";
|
||||
let ast = parse(src);
|
||||
let folds = folding::folding_ranges(&ast, folding::line_count(src));
|
||||
// A heading on the only line of the document covers nothing
|
||||
// foldable, so it's omitted.
|
||||
let none_for_solo = folds
|
||||
.iter()
|
||||
.all(|f| !(f.start_line == 0 && f.end_line == 0));
|
||||
assert!(none_for_solo, "got: {folds:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fold_kind_is_region() {
|
||||
use tower_lsp::lsp_types::FoldingRangeKind;
|
||||
let src = "= H =\nbody\n";
|
||||
let ast = parse(src);
|
||||
let folds = folding::folding_ranges(&ast, folding::line_count(src));
|
||||
assert!(!folds.is_empty());
|
||||
assert_eq!(folds[0].kind, Some(FoldingRangeKind::Region));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_count_matches_intuitive_definition() {
|
||||
assert_eq!(folding::line_count(""), 0);
|
||||
assert_eq!(folding::line_count("one"), 1);
|
||||
assert_eq!(folding::line_count("one\n"), 2); // trailing newline → virtual empty line
|
||||
assert_eq!(folding::line_count("one\ntwo"), 2);
|
||||
assert_eq!(folding::line_count("one\ntwo\n"), 3);
|
||||
}
|
||||
@@ -1,255 +0,0 @@
|
||||
//! Tests for the pure helpers that the LSP backend uses to translate
|
||||
//! between `nuwiki-core` ASTs and the LSP wire format. The async
|
||||
//! request-handling loop is exercised at integration time by the
|
||||
//! navigation tests, so this file stays focused on conversion correctness.
|
||||
|
||||
use nuwiki_core::ast::{
|
||||
inline::InlineNode, BlockNode, BlockquoteNode, DocumentNode, ErrorNode, ParagraphNode,
|
||||
Position, Span, TextNode,
|
||||
};
|
||||
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
|
||||
use nuwiki_core::syntax::SyntaxPlugin;
|
||||
use nuwiki_lsp::{ast_diagnostics, headings_to_symbols, to_lsp_position, to_lsp_range};
|
||||
use tower_lsp::lsp_types::{DiagnosticSeverity, DocumentSymbolResponse, SymbolKind};
|
||||
|
||||
// ===== Position / Range conversion =====
|
||||
|
||||
#[test]
|
||||
fn position_passthrough_in_utf8_mode() {
|
||||
let pos = Position {
|
||||
line: 3,
|
||||
column: 7,
|
||||
offset: 0,
|
||||
};
|
||||
let lsp = to_lsp_position(&pos, "ignored", true);
|
||||
assert_eq!(lsp.line, 3);
|
||||
assert_eq!(lsp.character, 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn position_translates_to_utf16_for_multibyte_chars() {
|
||||
// "héllo" in UTF-8: h(1) é(2 bytes) l(1) l(1) o(1) — total 6 bytes.
|
||||
// In UTF-16 each char is one code unit (BMP), so byte col 3 ("after é")
|
||||
// maps to UTF-16 char col 2.
|
||||
let line = "héllo\nworld\n";
|
||||
let pos = Position {
|
||||
line: 0,
|
||||
column: 3,
|
||||
offset: 3,
|
||||
};
|
||||
let lsp = to_lsp_position(&pos, line, false);
|
||||
assert_eq!(lsp.line, 0);
|
||||
assert_eq!(lsp.character, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn position_handles_astral_plane_in_utf16() {
|
||||
// 🦀 is U+1F980, two UTF-16 code units (surrogate pair), four UTF-8 bytes.
|
||||
let text = "🦀x\n";
|
||||
// After the crab + x, byte col is 5, UTF-16 col is 3 (2 surrogates + 1 BMP).
|
||||
let pos = Position {
|
||||
line: 0,
|
||||
column: 5,
|
||||
offset: 5,
|
||||
};
|
||||
let lsp = to_lsp_position(&pos, text, false);
|
||||
assert_eq!(lsp.character, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn range_passthrough_in_utf8_mode() {
|
||||
let span = Span::new(
|
||||
Position {
|
||||
line: 0,
|
||||
column: 0,
|
||||
offset: 0,
|
||||
},
|
||||
Position {
|
||||
line: 0,
|
||||
column: 5,
|
||||
offset: 5,
|
||||
},
|
||||
);
|
||||
let r = to_lsp_range(&span, "hello\n", true);
|
||||
assert_eq!(r.start.line, 0);
|
||||
assert_eq!(r.start.character, 0);
|
||||
assert_eq!(r.end.character, 5);
|
||||
}
|
||||
|
||||
// ===== Diagnostics =====
|
||||
|
||||
#[test]
|
||||
fn ast_diagnostics_yields_one_per_error_node() {
|
||||
let span_a = Span::new(
|
||||
Position {
|
||||
line: 0,
|
||||
column: 0,
|
||||
offset: 0,
|
||||
},
|
||||
Position {
|
||||
line: 0,
|
||||
column: 4,
|
||||
offset: 4,
|
||||
},
|
||||
);
|
||||
let span_b = Span::new(
|
||||
Position {
|
||||
line: 2,
|
||||
column: 0,
|
||||
offset: 10,
|
||||
},
|
||||
Position {
|
||||
line: 2,
|
||||
column: 3,
|
||||
offset: 13,
|
||||
},
|
||||
);
|
||||
let doc = DocumentNode {
|
||||
span: Span::default(),
|
||||
metadata: Default::default(),
|
||||
children: vec![
|
||||
BlockNode::Error(ErrorNode {
|
||||
span: span_a,
|
||||
raw: "first".into(),
|
||||
message: "first parse failure".into(),
|
||||
}),
|
||||
BlockNode::Paragraph(ParagraphNode {
|
||||
span: Span::default(),
|
||||
children: vec![InlineNode::Text(TextNode {
|
||||
span: Span::default(),
|
||||
content: "ok".into(),
|
||||
})],
|
||||
}),
|
||||
BlockNode::Error(ErrorNode {
|
||||
span: span_b,
|
||||
raw: "second".into(),
|
||||
message: "second parse failure".into(),
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
let diags = ast_diagnostics(&doc, "ignored", true);
|
||||
assert_eq!(diags.len(), 2);
|
||||
assert_eq!(diags[0].message, "first parse failure");
|
||||
assert_eq!(diags[0].severity, Some(DiagnosticSeverity::ERROR));
|
||||
assert_eq!(diags[0].source.as_deref(), Some("nuwiki"));
|
||||
assert_eq!(diags[0].range.start.line, 0);
|
||||
assert_eq!(diags[1].range.start.line, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ast_diagnostics_descends_into_blockquotes() {
|
||||
let span = Span::new(
|
||||
Position {
|
||||
line: 1,
|
||||
column: 2,
|
||||
offset: 0,
|
||||
},
|
||||
Position {
|
||||
line: 1,
|
||||
column: 6,
|
||||
offset: 4,
|
||||
},
|
||||
);
|
||||
let inner_error = BlockNode::Error(ErrorNode {
|
||||
span,
|
||||
raw: "x".into(),
|
||||
message: "nested".into(),
|
||||
});
|
||||
let bq = BlockNode::Blockquote(BlockquoteNode {
|
||||
span: Span::default(),
|
||||
children: vec![inner_error],
|
||||
});
|
||||
let doc = DocumentNode {
|
||||
span: Span::default(),
|
||||
metadata: Default::default(),
|
||||
children: vec![bq],
|
||||
};
|
||||
|
||||
let diags = ast_diagnostics(&doc, "", true);
|
||||
assert_eq!(diags.len(), 1);
|
||||
assert_eq!(diags[0].message, "nested");
|
||||
}
|
||||
|
||||
// ===== Document symbols =====
|
||||
|
||||
fn parse(src: &str) -> DocumentNode {
|
||||
VimwikiSyntax::new().parse(src)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_outline_when_all_headings_same_level() {
|
||||
let doc = parse("= A =\n= B =\n= C =\n");
|
||||
let syms = headings_to_symbols(&doc, "", true);
|
||||
assert_eq!(syms.len(), 3);
|
||||
let names: Vec<&str> = syms.iter().map(|s| s.name.as_str()).collect();
|
||||
assert_eq!(names, vec!["A", "B", "C"]);
|
||||
for s in &syms {
|
||||
assert!(s.children.is_none() || s.children.as_ref().unwrap().is_empty());
|
||||
assert_eq!(s.kind, SymbolKind::STRING);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_outline_follows_heading_levels() {
|
||||
let src = "\
|
||||
= Top =
|
||||
== Sub A ==
|
||||
=== Deeper ===
|
||||
== Sub B ==
|
||||
= Other =
|
||||
";
|
||||
let doc = parse(src);
|
||||
let syms = headings_to_symbols(&doc, "", true);
|
||||
assert_eq!(syms.len(), 2);
|
||||
|
||||
// Top contains Sub A and Sub B; Sub A contains Deeper.
|
||||
let top = &syms[0];
|
||||
assert_eq!(top.name, "Top");
|
||||
let top_kids = top.children.as_ref().expect("Top should have children");
|
||||
assert_eq!(top_kids.len(), 2);
|
||||
assert_eq!(top_kids[0].name, "Sub A");
|
||||
assert_eq!(top_kids[1].name, "Sub B");
|
||||
|
||||
let sub_a_kids = top_kids[0]
|
||||
.children
|
||||
.as_ref()
|
||||
.expect("Sub A should have children");
|
||||
assert_eq!(sub_a_kids.len(), 1);
|
||||
assert_eq!(sub_a_kids[0].name, "Deeper");
|
||||
|
||||
assert_eq!(syms[1].name, "Other");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heading_title_strips_inline_markers() {
|
||||
let doc = parse("= Hello *world* and `code` =\n");
|
||||
let syms = headings_to_symbols(&doc, "", true);
|
||||
assert_eq!(syms.len(), 1);
|
||||
assert_eq!(syms[0].name, "Hello world and code");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_heading_falls_back_to_placeholder_name() {
|
||||
let doc = parse("= =\n");
|
||||
let syms = headings_to_symbols(&doc, "", true);
|
||||
if let Some(s) = syms.first() {
|
||||
assert!(!s.name.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
// ===== End-to-end through the registry =====
|
||||
|
||||
#[test]
|
||||
fn outline_symbols_match_registry_dispatch() {
|
||||
let src = "= A =\n== B ==\n";
|
||||
let plugin = VimwikiSyntax::new();
|
||||
let doc = plugin.parse(src);
|
||||
let syms = headings_to_symbols(&doc, src, true);
|
||||
assert_eq!(syms.len(), 1);
|
||||
assert_eq!(syms[0].name, "A");
|
||||
let kids = syms[0].children.as_ref().unwrap();
|
||||
assert_eq!(kids[0].name, "B");
|
||||
// Sanity: symbols can be wrapped in DocumentSymbolResponse::Nested.
|
||||
let _resp = DocumentSymbolResponse::Nested(syms);
|
||||
}
|
||||
@@ -1,537 +0,0 @@
|
||||
//! HTML export commands.
|
||||
//!
|
||||
//! Drives the pure `export::*` helpers directly. The side-effecting
|
||||
//! `export_ops::write_page` / `write_rss` paths get exercised via a
|
||||
//! per-test temp dir so we know writes land where they should and
|
||||
//! the rendered HTML contains the substituted vars.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use nuwiki_core::date::DiaryDate;
|
||||
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
|
||||
use nuwiki_core::syntax::SyntaxPlugin;
|
||||
use nuwiki_lsp::config::{HtmlConfig, WikiConfig};
|
||||
use nuwiki_lsp::export;
|
||||
|
||||
fn parse(src: &str) -> nuwiki_core::ast::DocumentNode {
|
||||
VimwikiSyntax::new().parse(src)
|
||||
}
|
||||
|
||||
fn cfg(root: &str) -> WikiConfig {
|
||||
WikiConfig::from_root(PathBuf::from(root))
|
||||
}
|
||||
|
||||
fn temp_root(suffix: &str) -> PathBuf {
|
||||
let p = std::env::temp_dir().join(format!("nuwiki-p17-{suffix}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&p);
|
||||
std::fs::create_dir_all(&p).unwrap();
|
||||
p
|
||||
}
|
||||
|
||||
// ===== HtmlConfig defaults =====
|
||||
|
||||
#[test]
|
||||
fn html_config_defaults_match_vimwiki() {
|
||||
let c = HtmlConfig::default();
|
||||
assert_eq!(c.template_default, "default");
|
||||
assert_eq!(c.template_ext, ".tpl");
|
||||
assert_eq!(c.template_date_format, "%Y-%m-%d");
|
||||
assert_eq!(c.css_name, "style.css");
|
||||
assert!(!c.auto_export);
|
||||
assert!(!c.html_filename_parameterization);
|
||||
assert!(c.exclude_files.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn html_config_from_root_uses_underscore_dirs() {
|
||||
let h = HtmlConfig::from_root(Path::new("/tmp/wiki"));
|
||||
assert_eq!(h.html_path, PathBuf::from("/tmp/wiki/_html"));
|
||||
assert_eq!(h.template_path, PathBuf::from("/tmp/wiki/_templates"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wiki_config_picks_up_html_paths() {
|
||||
let c = cfg("/tmp/x");
|
||||
assert_eq!(c.html.html_path, PathBuf::from("/tmp/x/_html"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_from_json_honours_explicit_html_keys() {
|
||||
use nuwiki_lsp::config::config_from_json;
|
||||
let c = config_from_json(serde_json::json!({
|
||||
"wikis": [{
|
||||
"root": "/tmp/y",
|
||||
"html_path": "/tmp/y/site",
|
||||
"template_default": "post",
|
||||
"template_ext": ".tmpl",
|
||||
"auto_export": true,
|
||||
"html_filename_parameterization": true,
|
||||
"exclude_files": ["_drafts/**"],
|
||||
}],
|
||||
}));
|
||||
let h = &c.wikis[0].html;
|
||||
assert_eq!(h.html_path, PathBuf::from("/tmp/y/site"));
|
||||
assert_eq!(h.template_default, "post");
|
||||
assert_eq!(h.template_ext, ".tmpl");
|
||||
assert!(h.auto_export);
|
||||
assert!(h.html_filename_parameterization);
|
||||
assert_eq!(h.exclude_files, vec!["_drafts/**"]);
|
||||
}
|
||||
|
||||
// ===== format_date =====
|
||||
|
||||
#[test]
|
||||
fn format_date_strftime_subset() {
|
||||
let d = DiaryDate::from_ymd(2026, 5, 11).unwrap();
|
||||
assert_eq!(export::format_date(&d, "%Y-%m-%d"), "2026-05-11");
|
||||
assert_eq!(export::format_date(&d, "%Y"), "2026");
|
||||
assert_eq!(export::format_date(&d, "%m/%d/%Y"), "05/11/2026");
|
||||
assert_eq!(export::format_date(&d, "literal text"), "literal text");
|
||||
assert_eq!(
|
||||
export::format_date(&d, "100%% done"),
|
||||
"100% done",
|
||||
"%% escapes to a literal %"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_date_unknown_specifier_passes_through() {
|
||||
let d = DiaryDate::from_ymd(2026, 5, 11).unwrap();
|
||||
// We support Y/m/d/%; %H is not supported and should pass through verbatim.
|
||||
assert_eq!(export::format_date(&d, "%H:00"), "%H:00");
|
||||
}
|
||||
|
||||
// ===== compute_root_path =====
|
||||
|
||||
#[test]
|
||||
fn compute_root_path_is_empty_at_top_level() {
|
||||
assert_eq!(export::compute_root_path("Home"), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compute_root_path_adds_one_dotdot_per_subdir() {
|
||||
assert_eq!(export::compute_root_path("diary/2026-05-11"), "../");
|
||||
assert_eq!(export::compute_root_path("a/b/c"), "../../");
|
||||
}
|
||||
|
||||
// ===== output_path_for =====
|
||||
|
||||
#[test]
|
||||
fn output_path_for_basic() {
|
||||
let c = cfg("/tmp/x");
|
||||
assert_eq!(
|
||||
export::output_path_for(&c.html, "Home"),
|
||||
PathBuf::from("/tmp/x/_html/Home.html")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_path_for_nested_preserves_subdirs() {
|
||||
let c = cfg("/tmp/x");
|
||||
assert_eq!(
|
||||
export::output_path_for(&c.html, "diary/2026-05-11"),
|
||||
PathBuf::from("/tmp/x/_html/diary/2026-05-11.html")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_path_for_slugifies_only_the_filename_when_enabled() {
|
||||
let mut c = cfg("/tmp/x");
|
||||
c.html.html_filename_parameterization = true;
|
||||
let out = export::output_path_for(&c.html, "Notes/My Page!");
|
||||
// subdir keeps casing, filename becomes lowercase + url-safe.
|
||||
assert_eq!(out, PathBuf::from("/tmp/x/_html/notes/my-page.html"));
|
||||
}
|
||||
|
||||
// ===== is_excluded =====
|
||||
|
||||
#[test]
|
||||
fn is_excluded_matches_globstar() {
|
||||
let patterns = vec!["_drafts/**".into()];
|
||||
assert!(export::is_excluded(&patterns, "_drafts/foo"));
|
||||
assert!(export::is_excluded(&patterns, "_drafts/x/y"));
|
||||
assert!(!export::is_excluded(&patterns, "notes/x"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_excluded_matches_simple_glob() {
|
||||
let patterns = vec!["*.tmp".into()];
|
||||
assert!(export::is_excluded(&patterns, "foo.tmp"));
|
||||
assert!(!export::is_excluded(&patterns, "foo.wiki"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_excluded_matches_question_mark() {
|
||||
let patterns = vec!["??".into()];
|
||||
assert!(export::is_excluded(&patterns, "ab"));
|
||||
assert!(!export::is_excluded(&patterns, "abc"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_excluded_no_match_returns_false() {
|
||||
let patterns = vec!["foo".into()];
|
||||
assert!(!export::is_excluded(&patterns, "bar"));
|
||||
}
|
||||
|
||||
// ===== TOC HTML =====
|
||||
|
||||
#[test]
|
||||
fn build_toc_html_produces_nested_lists() {
|
||||
let ast = parse("= One =\n== Two ==\n=== Three ===\n= Four =\n");
|
||||
let html = export::build_toc_html(&ast);
|
||||
assert!(html.contains("ul class=\"toc\""));
|
||||
assert!(html.contains(">One<"));
|
||||
assert!(html.contains(">Two<"));
|
||||
assert!(html.contains(">Three<"));
|
||||
assert!(html.contains(">Four<"));
|
||||
// Verify the nesting: Two should be inside an inner <ul> after One.
|
||||
let one_idx = html.find(">One<").unwrap();
|
||||
let two_idx = html.find(">Two<").unwrap();
|
||||
let three_idx = html.find(">Three<").unwrap();
|
||||
assert!(one_idx < two_idx && two_idx < three_idx);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_toc_html_empty_for_no_headings() {
|
||||
let ast = parse("Just a paragraph.\n");
|
||||
assert!(export::build_toc_html(&ast).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_toc_html_escapes_special_chars_in_title() {
|
||||
let ast = parse("= A & B <ok> =\n");
|
||||
let html = export::build_toc_html(&ast);
|
||||
assert!(html.contains("A & B <ok>"));
|
||||
}
|
||||
|
||||
// ===== template_for =====
|
||||
|
||||
#[test]
|
||||
fn template_for_prefers_metadata_template() {
|
||||
let c = cfg("/tmp/x");
|
||||
let ast = parse("%template post\n= Hi =\n");
|
||||
let src = export::template_for(&c.html, &ast);
|
||||
assert_eq!(
|
||||
src.primary,
|
||||
Some(PathBuf::from("/tmp/x/_templates/post.tpl"))
|
||||
);
|
||||
assert_eq!(src.fallback, PathBuf::from("/tmp/x/_templates/default.tpl"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn template_for_falls_back_when_no_directive() {
|
||||
let c = cfg("/tmp/x");
|
||||
let ast = parse("= Hi =\n");
|
||||
let src = export::template_for(&c.html, &ast);
|
||||
assert!(src.primary.is_none());
|
||||
assert_eq!(src.fallback, PathBuf::from("/tmp/x/_templates/default.tpl"));
|
||||
}
|
||||
|
||||
// ===== render_page_html =====
|
||||
|
||||
#[test]
|
||||
fn render_page_html_substitutes_title_content_and_extras() {
|
||||
let ast = parse("%title My Page\n= One =\nbody text\n");
|
||||
let c = cfg("/tmp/x");
|
||||
let template = "<title>{{title}}</title><body>{{content}}</body><footer>{{date}}</footer>";
|
||||
let extras = HashMap::new();
|
||||
let html = export::render_page_html(
|
||||
&ast,
|
||||
Some(template.into()),
|
||||
"Home",
|
||||
DiaryDate::from_ymd(2026, 5, 11).unwrap(),
|
||||
&c.html,
|
||||
None,
|
||||
-1,
|
||||
extras,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(html.contains("<title>My Page</title>"));
|
||||
assert!(html.contains("<footer>2026-05-11</footer>"));
|
||||
assert!(html.contains("body text"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_page_html_uses_metadata_date_when_set() {
|
||||
let ast = parse("%date 2025-01-02\n= Hi =\n");
|
||||
let c = cfg("/tmp/x");
|
||||
let html = export::render_page_html(
|
||||
&ast,
|
||||
Some("DATE={{date}}".into()),
|
||||
"Home",
|
||||
DiaryDate::from_ymd(2026, 5, 11).unwrap(),
|
||||
&c.html,
|
||||
None,
|
||||
-1,
|
||||
HashMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(html.contains("DATE=2025-01-02"), "got: {html}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_page_html_root_path_for_nested_pages() {
|
||||
let ast = parse("= D =\n");
|
||||
let c = cfg("/tmp/x");
|
||||
let html = export::render_page_html(
|
||||
&ast,
|
||||
Some("CSS={{root_path}}{{css}}".into()),
|
||||
"diary/2026-05-11",
|
||||
DiaryDate::today_utc(),
|
||||
&c.html,
|
||||
None,
|
||||
-1,
|
||||
HashMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(html.contains("CSS=../style.css"), "got: {html}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_page_html_extra_var_overrides_default() {
|
||||
let ast = parse("= H =\n");
|
||||
let c = cfg("/tmp/x");
|
||||
let mut extras = HashMap::new();
|
||||
extras.insert("date".into(), "OVERRIDE".into());
|
||||
let html = export::render_page_html(
|
||||
&ast,
|
||||
Some("D={{date}}".into()),
|
||||
"H",
|
||||
DiaryDate::today_utc(),
|
||||
&c.html,
|
||||
None,
|
||||
-1,
|
||||
extras,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(html.contains("D=OVERRIDE"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_page_html_substitutes_vimwiki_percent_template() {
|
||||
// A stock vimwiki template (%title%/%content%/%root_path%/%wiki_css%/%date%)
|
||||
// must render fully — every placeholder resolved, no literal `%…%` left.
|
||||
let ast = parse("%title My Page\n= One =\nbody text\n");
|
||||
let c = cfg("/tmp/x");
|
||||
let template = "<title>%title%</title>\
|
||||
<link href=\"%root_path%%wiki_css%\">\
|
||||
<body>%content%</body><footer>%date%</footer>";
|
||||
let html = export::render_page_html(
|
||||
&ast,
|
||||
Some(template.into()),
|
||||
"diary/2026-05-11",
|
||||
DiaryDate::from_ymd(2026, 5, 11).unwrap(),
|
||||
&c.html,
|
||||
None,
|
||||
-1,
|
||||
HashMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(html.contains("<title>My Page</title>"), "title: {html}");
|
||||
assert!(html.contains("body text"), "content: {html}");
|
||||
assert!(
|
||||
html.contains("href=\"../style.css\""),
|
||||
"root_path+css: {html}"
|
||||
);
|
||||
assert!(html.contains("<footer>2026-05-11</footer>"), "date: {html}");
|
||||
assert!(!html.contains('%'), "placeholder left behind: {html}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_page_html_strips_wiki_extension_from_links() {
|
||||
// A link written with the extension (`[[todo.wiki]]`) must export to
|
||||
// `todo.html`, not `todo.wiki.html` — matching in-editor navigation.
|
||||
let ast = parse("[[todo.wiki|Tasks]] and [[posts/index|Posts]]\n");
|
||||
let c = cfg("/tmp/x");
|
||||
let html = export::render_page_html(
|
||||
&ast,
|
||||
Some("{{content}}".into()),
|
||||
"index",
|
||||
DiaryDate::today_utc(),
|
||||
&c.html,
|
||||
Some(".wiki"),
|
||||
-1,
|
||||
HashMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(html.contains("href=\"todo.html\""), "stripped: {html}");
|
||||
assert!(!html.contains("todo.wiki.html"), "not double-ext: {html}");
|
||||
// A link without the extension is unaffected.
|
||||
assert!(html.contains("href=\"posts/index.html\""), "plain: {html}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_page_html_list_margin_only_styles_top_level_list() {
|
||||
// `list_margin >= 0` forces a `margin-left:<n>em` on the outermost
|
||||
// list; nested lists and the default of `-1` stay unstyled.
|
||||
let ast = parse("- a\n - b\n");
|
||||
let c = cfg("/tmp/x");
|
||||
|
||||
let styled = export::render_page_html(
|
||||
&ast,
|
||||
Some("{{content}}".into()),
|
||||
"index",
|
||||
DiaryDate::today_utc(),
|
||||
&c.html,
|
||||
None,
|
||||
2,
|
||||
HashMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
styled.contains("<ul style=\"margin-left:2em\">"),
|
||||
"top-level margin: {styled}"
|
||||
);
|
||||
// Exactly one styled list — the nested `<ul>` is plain.
|
||||
assert_eq!(
|
||||
styled.matches("margin-left").count(),
|
||||
1,
|
||||
"only top: {styled}"
|
||||
);
|
||||
|
||||
let plain = export::render_page_html(
|
||||
&ast,
|
||||
Some("{{content}}".into()),
|
||||
"index",
|
||||
DiaryDate::today_utc(),
|
||||
&c.html,
|
||||
None,
|
||||
-1,
|
||||
HashMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!plain.contains("margin-left"), "default unstyled: {plain}");
|
||||
}
|
||||
|
||||
// ===== fallback_template + DEFAULT_CSS =====
|
||||
|
||||
#[test]
|
||||
fn fallback_template_references_root_path_and_css() {
|
||||
let t = export::fallback_template("custom.css");
|
||||
assert!(t.contains("{{title}}"));
|
||||
assert!(t.contains("{{content}}"));
|
||||
assert!(t.contains("{{root_path}}custom.css"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_css_is_non_empty() {
|
||||
assert!(!export::DEFAULT_CSS.is_empty());
|
||||
assert!(export::DEFAULT_CSS.contains("font-family"));
|
||||
}
|
||||
|
||||
// ===== write_page (disk-touching) =====
|
||||
|
||||
#[test]
|
||||
fn write_page_writes_html_to_disk() {
|
||||
let root = temp_root("write");
|
||||
let mut c = WikiConfig::from_root(root.clone());
|
||||
c.html = HtmlConfig::from_root(&root);
|
||||
let ast = parse("= Hello =\nworld\n");
|
||||
let outcome = nuwiki_lsp::commands::export_ops::write_page(&ast, "Hello", &c).unwrap();
|
||||
assert_eq!(outcome.page, "Hello");
|
||||
assert!(outcome.output_path.exists());
|
||||
let body = std::fs::read_to_string(&outcome.output_path).unwrap();
|
||||
assert!(body.contains("Hello"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_page_creates_subdirs() {
|
||||
let root = temp_root("subdir");
|
||||
let mut c = WikiConfig::from_root(root.clone());
|
||||
c.html = HtmlConfig::from_root(&root);
|
||||
let ast = parse("= Daily =\n");
|
||||
let outcome =
|
||||
nuwiki_lsp::commands::export_ops::write_page(&ast, "diary/2026-05-11", &c).unwrap();
|
||||
assert!(outcome.output_path.exists());
|
||||
assert!(outcome
|
||||
.output_path
|
||||
.to_string_lossy()
|
||||
.contains("diary/2026-05-11.html"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_page_uses_template_file_when_present() {
|
||||
let root = temp_root("template");
|
||||
let mut c = WikiConfig::from_root(root.clone());
|
||||
c.html = HtmlConfig::from_root(&root);
|
||||
// Write a template file the renderer should pick up.
|
||||
std::fs::create_dir_all(&c.html.template_path).unwrap();
|
||||
std::fs::write(
|
||||
c.html.template_path.join("default.tpl"),
|
||||
"<x>{{title}}|{{content}}</x>",
|
||||
)
|
||||
.unwrap();
|
||||
let ast = parse("%title T\n= H =\nbody\n");
|
||||
let outcome = nuwiki_lsp::commands::export_ops::write_page(&ast, "P", &c).unwrap();
|
||||
let body = std::fs::read_to_string(outcome.output_path).unwrap();
|
||||
assert!(body.starts_with("<x>T|"));
|
||||
assert!(body.ends_with("</x>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_css_writes_default_when_missing() {
|
||||
let root = temp_root("css");
|
||||
let mut c = WikiConfig::from_root(root.clone());
|
||||
c.html = HtmlConfig::from_root(&root);
|
||||
let res = nuwiki_lsp::commands::export_ops::ensure_css(&c).unwrap();
|
||||
assert!(res.created);
|
||||
assert!(res.path.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_css_is_idempotent_when_present() {
|
||||
let root = temp_root("css2");
|
||||
let mut c = WikiConfig::from_root(root.clone());
|
||||
c.html = HtmlConfig::from_root(&root);
|
||||
nuwiki_lsp::commands::export_ops::ensure_css(&c).unwrap();
|
||||
let res = nuwiki_lsp::commands::export_ops::ensure_css(&c).unwrap();
|
||||
assert!(!res.created);
|
||||
}
|
||||
|
||||
// ===== RSS =====
|
||||
|
||||
#[test]
|
||||
fn write_rss_emits_valid_xml_with_entries() {
|
||||
use nuwiki_lsp::diary::DiaryEntry;
|
||||
use tower_lsp::lsp_types::Url;
|
||||
|
||||
let root = temp_root("rss");
|
||||
let mut c = WikiConfig::from_root(root.clone());
|
||||
c.html = HtmlConfig::from_root(&root);
|
||||
c.name = "Test Wiki".into();
|
||||
let entries = vec![
|
||||
DiaryEntry {
|
||||
date: DiaryDate::from_ymd(2026, 5, 11).unwrap(),
|
||||
uri: Url::parse("file:///tmp/diary/2026-05-11.wiki").unwrap(),
|
||||
},
|
||||
DiaryEntry {
|
||||
date: DiaryDate::from_ymd(2026, 5, 10).unwrap(),
|
||||
uri: Url::parse("file:///tmp/diary/2026-05-10.wiki").unwrap(),
|
||||
},
|
||||
];
|
||||
let path = nuwiki_lsp::commands::export_ops::write_rss(&c, &entries).unwrap();
|
||||
let xml = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(xml.starts_with("<?xml"));
|
||||
assert!(xml.contains("<title>Test Wiki</title>"));
|
||||
// Newest first.
|
||||
let i11 = xml.find("2026-05-11").unwrap();
|
||||
let i10 = xml.find("2026-05-10").unwrap();
|
||||
assert!(i11 < i10);
|
||||
}
|
||||
|
||||
// ===== COMMANDS list completeness =====
|
||||
|
||||
#[test]
|
||||
fn commands_list_includes_export_entries() {
|
||||
let names: Vec<&str> = nuwiki_lsp::commands::COMMANDS.to_vec();
|
||||
for name in [
|
||||
"nuwiki.export.currentToHtml",
|
||||
"nuwiki.export.allToHtml",
|
||||
"nuwiki.export.allToHtmlForce",
|
||||
"nuwiki.export.browse",
|
||||
"nuwiki.export.rss",
|
||||
] {
|
||||
assert!(names.contains(&name), "missing: {name}");
|
||||
}
|
||||
}
|
||||
@@ -1,491 +0,0 @@
|
||||
//! LSP-side workspace plumbing:
|
||||
//! - `WorkspaceEditBuilder` (changes-map vs document-changes promotion)
|
||||
//! and `TextEdit` UTF-8/UTF-16 conversion.
|
||||
//! - `Config` desugar from `initializationOptions` JSON (per-wiki keys,
|
||||
//! legacy single-wiki shape).
|
||||
//! - `Wiki` resolution: building wikis from raw config and matching
|
||||
//! URIs to the longest-prefix wiki root.
|
||||
//! - `collect_diagnostics` composition (parser errors + link-health).
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use nuwiki_core::ast::{
|
||||
inline::InlineNode, BlockNode, DocumentNode, ErrorNode, ParagraphNode, Position as AstPosition,
|
||||
Span, TextNode,
|
||||
};
|
||||
use nuwiki_lsp::collect_diagnostics;
|
||||
use nuwiki_lsp::config::{config_from_json, Config, DiagnosticConfig, LinkSeverity, WikiConfig};
|
||||
use nuwiki_lsp::edits::{
|
||||
op_create, op_delete, op_rename, text_edit_delete, text_edit_insert, text_edit_replace,
|
||||
WorkspaceEditBuilder,
|
||||
};
|
||||
use nuwiki_lsp::wiki::{build_wikis, resolve_uri_to_wiki, Wiki, WikiId};
|
||||
use serde_json::json;
|
||||
use tower_lsp::lsp_types::{DocumentChanges, Position as LspPosition, Url};
|
||||
|
||||
// ===== Edits =====
|
||||
|
||||
fn span_one_line(byte_col: u32, len: u32) -> Span {
|
||||
Span::new(
|
||||
AstPosition {
|
||||
line: 0,
|
||||
column: byte_col,
|
||||
offset: byte_col as usize,
|
||||
},
|
||||
AstPosition {
|
||||
line: 0,
|
||||
column: byte_col + len,
|
||||
offset: (byte_col + len) as usize,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_edit_replace_passthrough_in_utf8() {
|
||||
let edit = text_edit_replace(span_one_line(0, 5), "world".into(), "hello there", true);
|
||||
assert_eq!(edit.range.start.character, 0);
|
||||
assert_eq!(edit.range.end.character, 5);
|
||||
assert_eq!(edit.new_text, "world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_edit_replace_converts_to_utf16_for_multibyte() {
|
||||
// "héllo" — h(1B) é(2B) l(1B) l(1B) o(1B); UTF-16 chars: 1+1+1+1+1=5.
|
||||
// span covers bytes 0..3 (h + é); UTF-16 character count = 2.
|
||||
let edit = text_edit_replace(span_one_line(0, 3), "X".into(), "héllo", false);
|
||||
assert_eq!(edit.range.start.character, 0);
|
||||
assert_eq!(edit.range.end.character, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_edit_insert_produces_zero_width_range() {
|
||||
let edit = text_edit_insert(
|
||||
LspPosition {
|
||||
line: 0,
|
||||
character: 4,
|
||||
},
|
||||
"X".into(),
|
||||
);
|
||||
assert_eq!(edit.range.start, edit.range.end);
|
||||
assert_eq!(edit.new_text, "X");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_edit_delete_has_empty_new_text() {
|
||||
let edit = text_edit_delete(span_one_line(0, 3), "abcdef", true);
|
||||
assert_eq!(edit.new_text, "");
|
||||
assert_eq!(edit.range.end.character, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builder_with_only_edits_uses_changes_map() {
|
||||
let uri = Url::parse("file:///tmp/a.wiki").unwrap();
|
||||
let mut b = WorkspaceEditBuilder::new();
|
||||
b.edit(
|
||||
uri.clone(),
|
||||
text_edit_replace(span_one_line(0, 3), "new".into(), "old text", true),
|
||||
);
|
||||
let we = b.build();
|
||||
assert!(we.changes.is_some());
|
||||
assert!(we.document_changes.is_none());
|
||||
let changes = we.changes.unwrap();
|
||||
assert_eq!(changes.len(), 1);
|
||||
assert_eq!(changes[&uri].len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builder_with_file_op_promotes_to_document_changes() {
|
||||
let old = Url::parse("file:///tmp/A.wiki").unwrap();
|
||||
let new = Url::parse("file:///tmp/B.wiki").unwrap();
|
||||
let mut b = WorkspaceEditBuilder::new();
|
||||
b.file_op(op_rename(old.clone(), new.clone()));
|
||||
b.edit(
|
||||
new.clone(),
|
||||
text_edit_replace(span_one_line(0, 1), "X".into(), "Y", true),
|
||||
);
|
||||
let we = b.build();
|
||||
assert!(we.changes.is_none());
|
||||
let DocumentChanges::Operations(ops) = we.document_changes.unwrap() else {
|
||||
panic!("expected Operations variant");
|
||||
};
|
||||
// Rename comes first (file ops always precede content edits in the
|
||||
// builder), then the text edit on the renamed file.
|
||||
assert_eq!(ops.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builder_is_empty_until_something_pushed() {
|
||||
let mut b = WorkspaceEditBuilder::new();
|
||||
assert!(b.is_empty());
|
||||
b.file_op(op_delete(Url::parse("file:///x").unwrap()));
|
||||
assert!(!b.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn op_create_round_trips_through_builder() {
|
||||
let uri = Url::parse("file:///tmp/new.wiki").unwrap();
|
||||
let mut b = WorkspaceEditBuilder::new();
|
||||
b.file_op(op_create(uri));
|
||||
let we = b.build();
|
||||
assert!(we.document_changes.is_some());
|
||||
}
|
||||
|
||||
// ===== Config =====
|
||||
|
||||
#[test]
|
||||
fn config_default_is_empty() {
|
||||
let c = Config::default();
|
||||
assert!(c.wikis.is_empty());
|
||||
assert_eq!(c.diagnostic.link_severity, LinkSeverity::Warning);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v1_0_single_wiki_desugars_to_one_entry() {
|
||||
let cfg = config_from_json(json!({
|
||||
"wiki_root": "/tmp/vimwiki",
|
||||
"file_extension": ".wiki",
|
||||
"syntax": "vimwiki",
|
||||
}));
|
||||
assert_eq!(cfg.wikis.len(), 1);
|
||||
assert_eq!(cfg.wikis[0].root, PathBuf::from("/tmp/vimwiki"));
|
||||
assert_eq!(cfg.wikis[0].file_extension, ".wiki");
|
||||
assert_eq!(cfg.wikis[0].syntax, "vimwiki");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_wikis_list_overrides_legacy_shape() {
|
||||
let cfg = config_from_json(json!({
|
||||
"wiki_root": "/tmp/IGNORED",
|
||||
"wikis": [
|
||||
{ "root": "/tmp/personal", "name": "personal" },
|
||||
{ "root": "/tmp/work", "syntax": "vimwiki" },
|
||||
],
|
||||
}));
|
||||
assert_eq!(cfg.wikis.len(), 2);
|
||||
assert_eq!(cfg.wikis[0].name, "personal");
|
||||
assert_eq!(cfg.wikis[1].root, PathBuf::from("/tmp/work"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_json_yields_no_wikis() {
|
||||
let cfg = config_from_json(json!({}));
|
||||
assert!(cfg.wikis.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_json_leaves_existing_config_unchanged() {
|
||||
let mut cfg = Config::default();
|
||||
cfg.wikis
|
||||
.push(WikiConfig::from_root(PathBuf::from("/tmp/a")));
|
||||
cfg.apply_change(&json!("not a config object"));
|
||||
// Existing wikis still present.
|
||||
assert_eq!(cfg.wikis.len(), 1);
|
||||
}
|
||||
|
||||
// ===== Wiki aggregate =====
|
||||
|
||||
fn wiki_at(id: u32, root: &str) -> Wiki {
|
||||
Wiki::new(WikiId(id), WikiConfig::from_root(PathBuf::from(root)))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_wikis_assigns_sequential_ids() {
|
||||
let configs = vec![
|
||||
WikiConfig::from_root(PathBuf::from("/tmp/a")),
|
||||
WikiConfig::from_root(PathBuf::from("/tmp/b")),
|
||||
];
|
||||
let wikis = build_wikis(&configs);
|
||||
assert_eq!(wikis.len(), 2);
|
||||
assert_eq!(wikis[0].id, WikiId(0));
|
||||
assert_eq!(wikis[1].id, WikiId(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_uri_to_wiki_picks_longest_prefix() {
|
||||
let wikis = vec![wiki_at(0, "/wiki"), wiki_at(1, "/wiki/sub")];
|
||||
let uri = Url::from_file_path("/wiki/sub/page.wiki").unwrap();
|
||||
assert_eq!(resolve_uri_to_wiki(&wikis, &uri), Some(WikiId(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_uri_to_wiki_misses_outside_any_root() {
|
||||
let wikis = vec![wiki_at(0, "/wiki")];
|
||||
let uri = Url::from_file_path("/other/page.wiki").unwrap();
|
||||
assert!(resolve_uri_to_wiki(&wikis, &uri).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wiki_contains_respects_root_prefix() {
|
||||
let w = wiki_at(0, "/wiki");
|
||||
assert!(w.contains(&Url::from_file_path("/wiki/note.wiki").unwrap()));
|
||||
assert!(!w.contains(&Url::from_file_path("/other/note.wiki").unwrap()));
|
||||
}
|
||||
|
||||
// ===== Diagnostics composition =====
|
||||
|
||||
#[test]
|
||||
fn collect_diagnostics_emits_one_per_error_node() {
|
||||
let doc = DocumentNode {
|
||||
span: Span::default(),
|
||||
metadata: Default::default(),
|
||||
children: vec![
|
||||
BlockNode::Error(ErrorNode {
|
||||
span: span_one_line(0, 3),
|
||||
raw: "oops".into(),
|
||||
message: "broken".into(),
|
||||
}),
|
||||
BlockNode::Paragraph(ParagraphNode {
|
||||
span: Span::default(),
|
||||
children: vec![InlineNode::Text(TextNode {
|
||||
span: Span::default(),
|
||||
content: "ok".into(),
|
||||
})],
|
||||
}),
|
||||
],
|
||||
};
|
||||
let cfg = DiagnosticConfig::default();
|
||||
let diags = collect_diagnostics(&doc, "abc ok", None, None, None, &cfg, true);
|
||||
assert_eq!(diags.len(), 1);
|
||||
assert_eq!(diags[0].message, "broken");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_diagnostics_link_health_emits_nothing_without_index() {
|
||||
// No index → link-health source is skipped, even when severity is on.
|
||||
let doc = DocumentNode::default();
|
||||
let cfg = DiagnosticConfig {
|
||||
link_severity: LinkSeverity::Error,
|
||||
};
|
||||
let diags = collect_diagnostics(&doc, "", None, None, Some("Home"), &cfg, true);
|
||||
assert!(diags.is_empty());
|
||||
}
|
||||
|
||||
// ===== Per-wiki config defaults + JSON parsing =====
|
||||
|
||||
#[test]
|
||||
fn defaults_carry_vimwiki_per_wiki_keys() {
|
||||
let cfg = WikiConfig::empty();
|
||||
assert_eq!(cfg.index, "index");
|
||||
assert_eq!(cfg.diary_frequency, "daily");
|
||||
assert_eq!(cfg.diary_caption_level, 1);
|
||||
assert_eq!(cfg.diary_sort, "desc");
|
||||
assert_eq!(cfg.diary_header, "Diary");
|
||||
assert_eq!(cfg.listsyms, " .oOX");
|
||||
assert!(cfg.listsyms_propagate);
|
||||
assert_eq!(cfg.list_margin, -1);
|
||||
assert_eq!(cfg.links_space_char, " ");
|
||||
assert!(!cfg.auto_toc);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_wiki_parses_every_new_key() {
|
||||
let cfg = config_from_json(serde_json::json!({
|
||||
"wikis": [{
|
||||
"root": "/tmp/w",
|
||||
"index": "home",
|
||||
"diary_frequency": "weekly",
|
||||
"diary_caption_level": 2,
|
||||
"diary_sort": "asc",
|
||||
"diary_header": "Journal",
|
||||
"listsyms": "abcde",
|
||||
"listsyms_propagate": false,
|
||||
"list_margin": 2,
|
||||
"links_space_char": "_",
|
||||
"auto_toc": true,
|
||||
}],
|
||||
}));
|
||||
let w = &cfg.wikis[0];
|
||||
assert_eq!(w.index, "home");
|
||||
assert_eq!(w.diary_frequency, "weekly");
|
||||
assert_eq!(w.diary_caption_level, 2);
|
||||
assert_eq!(w.diary_sort, "asc");
|
||||
assert_eq!(w.diary_header, "Journal");
|
||||
assert_eq!(w.listsyms, "abcde");
|
||||
assert!(!w.listsyms_propagate);
|
||||
assert_eq!(w.list_margin, 2);
|
||||
assert_eq!(w.links_space_char, "_");
|
||||
assert!(w.auto_toc);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link_severity_parses_from_client_config() {
|
||||
for (input, expected) in [
|
||||
("off", LinkSeverity::Off),
|
||||
("hint", LinkSeverity::Hint),
|
||||
("warn", LinkSeverity::Warning),
|
||||
("warning", LinkSeverity::Warning),
|
||||
("error", LinkSeverity::Error),
|
||||
("ERROR", LinkSeverity::Error),
|
||||
] {
|
||||
let cfg = config_from_json(json!({
|
||||
"wiki_root": "/tmp/w",
|
||||
"diagnostic": { "link_severity": input },
|
||||
}));
|
||||
assert_eq!(
|
||||
cfg.diagnostic.link_severity, expected,
|
||||
"input {input:?} should map to {expected:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link_severity_invalid_or_absent_falls_back_to_default() {
|
||||
// Unknown string → default (Warning).
|
||||
let bad = config_from_json(json!({
|
||||
"wiki_root": "/tmp/w",
|
||||
"diagnostic": { "link_severity": "loud" },
|
||||
}));
|
||||
assert_eq!(bad.diagnostic.link_severity, LinkSeverity::Warning);
|
||||
|
||||
// No diagnostic key at all → default.
|
||||
let none = config_from_json(json!({ "wiki_root": "/tmp/w" }));
|
||||
assert_eq!(none.diagnostic.link_severity, LinkSeverity::Warning);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_change_preserves_severity_on_partial_update() {
|
||||
let mut cfg = config_from_json(json!({
|
||||
"wiki_root": "/tmp/w",
|
||||
"diagnostic": { "link_severity": "error" },
|
||||
}));
|
||||
assert_eq!(cfg.diagnostic.link_severity, LinkSeverity::Error);
|
||||
|
||||
// A later update that omits `diagnostic` must not reset severity.
|
||||
cfg.apply_change(&json!({ "wiki_root": "/tmp/w2" }));
|
||||
assert_eq!(cfg.diagnostic.link_severity, LinkSeverity::Error);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_change_minimal_diagnostic_payload_is_not_unwrapped() {
|
||||
// A lone `{ "diagnostic": { … } }` is a real flat payload, not a
|
||||
// `{ "nuwiki": { … } }` namespace wrapper. The single-key unwrap must
|
||||
// skip it (it's a recognised top-level key) so the severity applies.
|
||||
let mut cfg = config_from_json(json!({ "wiki_root": "/tmp/w" }));
|
||||
assert_eq!(cfg.diagnostic.link_severity, LinkSeverity::Warning);
|
||||
|
||||
cfg.apply_change(&json!({ "diagnostic": { "link_severity": "error" } }));
|
||||
assert_eq!(cfg.diagnostic.link_severity, LinkSeverity::Error);
|
||||
|
||||
// The Neovim namespace wrapper still unwraps correctly.
|
||||
cfg.apply_change(&json!({ "nuwiki": { "diagnostic": { "link_severity": "hint" } } }));
|
||||
assert_eq!(cfg.diagnostic.link_severity, LinkSeverity::Hint);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn severity_change_via_apply_change_is_observable_in_collected_diagnostics() {
|
||||
// Contract behind `did_change_configuration`'s diagnostic re-publish:
|
||||
// once `apply_change` updates the severity, re-running `collect_diagnostics`
|
||||
// (what the handler does for every open doc) must emit broken-link
|
||||
// diagnostics at the *new* severity. Guards the data path the handler
|
||||
// depends on — apply_change writing the severity AND collect_diagnostics
|
||||
// reading it.
|
||||
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
|
||||
use nuwiki_core::syntax::SyntaxPlugin;
|
||||
use nuwiki_lsp::index::WorkspaceIndex;
|
||||
use tower_lsp::lsp_types::DiagnosticSeverity;
|
||||
|
||||
let root = "/tmp/cfgchange";
|
||||
let src = "[[GhostPage]]\n";
|
||||
let ast = VimwikiSyntax::new().parse(src);
|
||||
let mut idx = WorkspaceIndex::new(Some(PathBuf::from(root)));
|
||||
let uri = Url::from_file_path(format!("{root}/Home.wiki")).unwrap();
|
||||
idx.upsert(uri.clone(), &ast);
|
||||
|
||||
let mut cfg = config_from_json(json!({
|
||||
"wiki_root": root,
|
||||
"diagnostic": { "link_severity": "warn" },
|
||||
}));
|
||||
let warn = collect_diagnostics(
|
||||
&ast,
|
||||
src,
|
||||
Some(&uri),
|
||||
Some(&idx),
|
||||
Some("Home"),
|
||||
&cfg.diagnostic,
|
||||
true,
|
||||
);
|
||||
assert_eq!(warn.len(), 1, "broken link should warn");
|
||||
assert_eq!(warn[0].severity, Some(DiagnosticSeverity::WARNING));
|
||||
|
||||
// Simulate the client raising severity via didChangeConfiguration.
|
||||
cfg.apply_change(&json!({ "diagnostic": { "link_severity": "error" } }));
|
||||
let err = collect_diagnostics(
|
||||
&ast,
|
||||
src,
|
||||
Some(&uri),
|
||||
Some(&idx),
|
||||
Some("Home"),
|
||||
&cfg.diagnostic,
|
||||
true,
|
||||
);
|
||||
assert_eq!(err.len(), 1);
|
||||
assert_eq!(
|
||||
err[0].severity,
|
||||
Some(DiagnosticSeverity::ERROR),
|
||||
"re-collected diagnostics must reflect the new severity"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vim_flat_and_neovim_wrapped_payloads_desugar_identically() {
|
||||
// Parity guard for the two client shapes:
|
||||
// * Vim (`autoload/nuwiki/lsp.vim`) → flat init_options dict.
|
||||
// * Neovim (`lua/nuwiki/lsp.lua`) → `{ nuwiki = { … } }` for
|
||||
// `workspace/didChangeConfiguration` (server_settings()).
|
||||
// Both must produce the same wikis; otherwise switching editors would
|
||||
// silently change how the server resolves links/diary/HTML paths.
|
||||
let inner = json!({
|
||||
"wiki_root": "/tmp/base",
|
||||
"file_extension": ".md",
|
||||
"syntax": "vimwiki",
|
||||
"log_level": "debug",
|
||||
"diagnostic": { "link_severity": "error" },
|
||||
"wikis": [
|
||||
{
|
||||
"name": "personal",
|
||||
"root": "/tmp/personal",
|
||||
"file_extension": ".wiki",
|
||||
"index": "home",
|
||||
"diary_rel_path": "journal",
|
||||
},
|
||||
{ "name": "work", "root": "/tmp/work" },
|
||||
],
|
||||
});
|
||||
|
||||
let flat = config_from_json(inner.clone());
|
||||
let wrapped = config_from_json(json!({ "nuwiki": inner }));
|
||||
|
||||
assert_eq!(
|
||||
flat.diagnostic.link_severity,
|
||||
wrapped.diagnostic.link_severity
|
||||
);
|
||||
assert_eq!(flat.diagnostic.link_severity, LinkSeverity::Error);
|
||||
assert_eq!(flat.wikis.len(), wrapped.wikis.len());
|
||||
for (a, b) in flat.wikis.iter().zip(wrapped.wikis.iter()) {
|
||||
assert_eq!(a.name, b.name);
|
||||
assert_eq!(a.root, b.root);
|
||||
assert_eq!(a.file_extension, b.file_extension);
|
||||
assert_eq!(a.syntax, b.syntax);
|
||||
assert_eq!(a.index, b.index);
|
||||
assert_eq!(a.diary_rel_path, b.diary_rel_path);
|
||||
}
|
||||
|
||||
// And the desugared list matches the per-wiki values both clients send.
|
||||
assert_eq!(flat.wikis[0].name, "personal");
|
||||
assert_eq!(flat.wikis[0].root, PathBuf::from("/tmp/personal"));
|
||||
assert_eq!(flat.wikis[0].file_extension, ".wiki");
|
||||
assert_eq!(flat.wikis[0].index, "home");
|
||||
assert_eq!(flat.wikis[0].diary_rel_path, "journal");
|
||||
assert_eq!(flat.wikis[1].name, "work");
|
||||
assert_eq!(flat.wikis[1].root, PathBuf::from("/tmp/work"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_single_wiki_shape_picks_up_defaults() {
|
||||
let cfg = config_from_json(serde_json::json!({
|
||||
"wiki_root": "/tmp/legacy",
|
||||
}));
|
||||
assert_eq!(cfg.wikis.len(), 1);
|
||||
assert_eq!(cfg.wikis[0].index, "index");
|
||||
assert_eq!(cfg.wikis[0].diary_frequency, "daily");
|
||||
}
|
||||
@@ -1,664 +0,0 @@
|
||||
//! Link-health diagnostics + TOC/links/orphans queries.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
|
||||
use nuwiki_core::syntax::SyntaxPlugin;
|
||||
use nuwiki_lsp::commands::ops;
|
||||
use nuwiki_lsp::config::LinkSeverity;
|
||||
use nuwiki_lsp::diagnostics::{self, BrokenLinkKind};
|
||||
use nuwiki_lsp::index::WorkspaceIndex;
|
||||
use tower_lsp::lsp_types::{DiagnosticSeverity, Url};
|
||||
|
||||
fn parse(src: &str) -> nuwiki_core::ast::DocumentNode {
|
||||
VimwikiSyntax::new().parse(src)
|
||||
}
|
||||
|
||||
fn build_index(root: &str, pages: &[(&str, &str)]) -> WorkspaceIndex {
|
||||
let mut idx = WorkspaceIndex::new(Some(PathBuf::from(root)));
|
||||
for (name, src) in pages {
|
||||
let ast = parse(src);
|
||||
let path = format!("{root}/{name}.wiki");
|
||||
let uri = Url::from_file_path(&path).unwrap();
|
||||
idx.upsert(uri, &ast);
|
||||
}
|
||||
idx
|
||||
}
|
||||
|
||||
fn home_uri(root: &str) -> Url {
|
||||
Url::from_file_path(format!("{root}/Home.wiki")).unwrap()
|
||||
}
|
||||
|
||||
// ===== severity_to_lsp =====
|
||||
|
||||
#[test]
|
||||
fn severity_off_returns_none() {
|
||||
assert!(diagnostics::severity_to_lsp(LinkSeverity::Off).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn severity_levels_round_trip() {
|
||||
assert_eq!(
|
||||
diagnostics::severity_to_lsp(LinkSeverity::Hint),
|
||||
Some(DiagnosticSeverity::HINT)
|
||||
);
|
||||
assert_eq!(
|
||||
diagnostics::severity_to_lsp(LinkSeverity::Warning),
|
||||
Some(DiagnosticSeverity::WARNING)
|
||||
);
|
||||
assert_eq!(
|
||||
diagnostics::severity_to_lsp(LinkSeverity::Error),
|
||||
Some(DiagnosticSeverity::ERROR)
|
||||
);
|
||||
}
|
||||
|
||||
// ===== link_health: wiki targets =====
|
||||
|
||||
#[test]
|
||||
fn link_to_missing_page_warns() {
|
||||
let root = "/tmp/lh1";
|
||||
let idx = build_index(root, &[("Home", "[[GhostPage]]\n")]);
|
||||
let src = "[[GhostPage]]\n";
|
||||
let ast = parse(src);
|
||||
let uri = home_uri(root);
|
||||
let diags = diagnostics::link_health(
|
||||
&ast,
|
||||
src,
|
||||
Some(&uri),
|
||||
&idx,
|
||||
"Home",
|
||||
LinkSeverity::Warning,
|
||||
true,
|
||||
);
|
||||
assert_eq!(diags.len(), 1);
|
||||
assert!(diags[0].message.contains("GhostPage"));
|
||||
assert_eq!(diags[0].severity, Some(DiagnosticSeverity::WARNING));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link_to_existing_page_silent() {
|
||||
let root = "/tmp/lh2";
|
||||
let idx = build_index(root, &[("Home", "[[Other]]\n"), ("Other", "= Other =\n")]);
|
||||
let src = "[[Other]]\n";
|
||||
let ast = parse(src);
|
||||
let diags = diagnostics::link_health(
|
||||
&ast,
|
||||
src,
|
||||
Some(&home_uri(root)),
|
||||
&idx,
|
||||
"Home",
|
||||
LinkSeverity::Warning,
|
||||
true,
|
||||
);
|
||||
assert!(diags.is_empty(), "got: {:?}", diags);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link_with_valid_anchor_silent() {
|
||||
let root = "/tmp/lh3";
|
||||
let idx = build_index(
|
||||
root,
|
||||
&[
|
||||
("Home", "[[Target#section-one]]\n"),
|
||||
("Target", "= Target =\n== Section One ==\n"),
|
||||
],
|
||||
);
|
||||
let src = "[[Target#section-one]]\n";
|
||||
let ast = parse(src);
|
||||
let diags = diagnostics::link_health(
|
||||
&ast,
|
||||
src,
|
||||
Some(&home_uri(root)),
|
||||
&idx,
|
||||
"Home",
|
||||
LinkSeverity::Warning,
|
||||
true,
|
||||
);
|
||||
assert!(diags.is_empty(), "got: {:?}", diags);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link_with_missing_anchor_warns() {
|
||||
let root = "/tmp/lh4";
|
||||
let idx = build_index(
|
||||
root,
|
||||
&[
|
||||
("Home", "[[Target#ghost-anchor]]\n"),
|
||||
("Target", "= Target =\n"),
|
||||
],
|
||||
);
|
||||
let src = "[[Target#ghost-anchor]]\n";
|
||||
let ast = parse(src);
|
||||
let diags = diagnostics::link_health(
|
||||
&ast,
|
||||
src,
|
||||
Some(&home_uri(root)),
|
||||
&idx,
|
||||
"Home",
|
||||
LinkSeverity::Warning,
|
||||
true,
|
||||
);
|
||||
assert_eq!(diags.len(), 1);
|
||||
assert!(diags[0].message.to_lowercase().contains("anchor"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anchor_only_link_resolves_against_current_page() {
|
||||
let root = "/tmp/lh5";
|
||||
let idx = build_index(root, &[("Home", "= Home =\n== Intro ==\n[[#intro]]\n")]);
|
||||
let src = "= Home =\n== Intro ==\n[[#intro]]\n";
|
||||
let ast = parse(src);
|
||||
let diags = diagnostics::link_health(
|
||||
&ast,
|
||||
src,
|
||||
Some(&home_uri(root)),
|
||||
&idx,
|
||||
"Home",
|
||||
LinkSeverity::Warning,
|
||||
true,
|
||||
);
|
||||
assert!(diags.is_empty(), "got: {:?}", diags);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anchor_only_link_to_missing_anchor_warns() {
|
||||
let root = "/tmp/lh6";
|
||||
let idx = build_index(root, &[("Home", "[[#nowhere]]\n")]);
|
||||
let src = "[[#nowhere]]\n";
|
||||
let ast = parse(src);
|
||||
let diags = diagnostics::link_health(
|
||||
&ast,
|
||||
src,
|
||||
Some(&home_uri(root)),
|
||||
&idx,
|
||||
"Home",
|
||||
LinkSeverity::Warning,
|
||||
true,
|
||||
);
|
||||
assert_eq!(diags.len(), 1);
|
||||
assert!(diags[0].message.contains("nowhere"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link_severity_off_emits_nothing() {
|
||||
let root = "/tmp/lh7";
|
||||
let idx = build_index(root, &[("Home", "[[GhostPage]]\n")]);
|
||||
let src = "[[GhostPage]]\n";
|
||||
let ast = parse(src);
|
||||
let diags = diagnostics::link_health(
|
||||
&ast,
|
||||
src,
|
||||
Some(&home_uri(root)),
|
||||
&idx,
|
||||
"Home",
|
||||
LinkSeverity::Off,
|
||||
true,
|
||||
);
|
||||
assert!(diags.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_url_never_emits_diagnostic() {
|
||||
let root = "/tmp/lh8";
|
||||
let idx = build_index(root, &[("Home", "https://example.com\n")]);
|
||||
let src = "https://example.com\n";
|
||||
let ast = parse(src);
|
||||
let diags = diagnostics::link_health(
|
||||
&ast,
|
||||
src,
|
||||
Some(&home_uri(root)),
|
||||
&idx,
|
||||
"Home",
|
||||
LinkSeverity::Error,
|
||||
true,
|
||||
);
|
||||
assert!(diags.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_as_anchor_resolves() {
|
||||
let root = "/tmp/lh9";
|
||||
let idx = build_index(
|
||||
root,
|
||||
&[
|
||||
("Home", "[[Notes#release]]\n"),
|
||||
("Notes", "= Notes =\n:release:\n"),
|
||||
],
|
||||
);
|
||||
let src = "[[Notes#release]]\n";
|
||||
let ast = parse(src);
|
||||
let diags = diagnostics::link_health(
|
||||
&ast,
|
||||
src,
|
||||
Some(&home_uri(root)),
|
||||
&idx,
|
||||
"Home",
|
||||
LinkSeverity::Warning,
|
||||
true,
|
||||
);
|
||||
assert!(diags.is_empty(), "got: {:?}", diags);
|
||||
}
|
||||
|
||||
// ===== BrokenLinkKind classification =====
|
||||
|
||||
#[test]
|
||||
fn classify_missing_page() {
|
||||
let kind = BrokenLinkKind::MissingPage("X".into());
|
||||
assert_eq!(kind.tag(), "missing-page");
|
||||
assert!(kind.message().contains("X"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_missing_anchor() {
|
||||
let kind = BrokenLinkKind::MissingAnchor {
|
||||
page: "P".into(),
|
||||
anchor: "a".into(),
|
||||
};
|
||||
assert_eq!(kind.tag(), "missing-anchor");
|
||||
assert!(kind.message().contains("a"));
|
||||
assert!(kind.message().contains("P"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_missing_file() {
|
||||
let kind = BrokenLinkKind::MissingFile(PathBuf::from("/tmp/nope.txt"));
|
||||
assert_eq!(kind.tag(), "missing-file");
|
||||
assert!(kind.message().contains("nope.txt"));
|
||||
}
|
||||
|
||||
// ===== heading_text =====
|
||||
|
||||
#[test]
|
||||
fn heading_text_strips_formatting() {
|
||||
let src = "= *Bold* heading =\n";
|
||||
let ast = parse(src);
|
||||
let h = match &ast.children[0] {
|
||||
nuwiki_core::ast::BlockNode::Heading(h) => h,
|
||||
_ => panic!("expected heading"),
|
||||
};
|
||||
let t = diagnostics::heading_text(&h.children);
|
||||
assert!(t.contains("Bold"));
|
||||
assert!(t.contains("heading"));
|
||||
}
|
||||
|
||||
// ===== TOC text generation =====
|
||||
|
||||
#[test]
|
||||
fn build_toc_text_nests_by_level() {
|
||||
let items = vec![
|
||||
(1u8, "Top".into(), "top".into()),
|
||||
(2u8, "Sub".into(), "sub".into()),
|
||||
(1u8, "Other".into(), "other".into()),
|
||||
];
|
||||
let out = ops::build_toc_text(&items, "Contents");
|
||||
assert!(out.starts_with("= Contents =\n"));
|
||||
let lines: Vec<&str> = out.lines().collect();
|
||||
assert_eq!(lines[0], "= Contents =");
|
||||
assert_eq!(lines[1], "- [[#top|Top]]");
|
||||
assert_eq!(lines[2], " - [[#sub|Sub]]");
|
||||
assert_eq!(lines[3], "- [[#other|Other]]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_toc_text_with_no_headings_is_just_the_heading() {
|
||||
let out = ops::build_toc_text(&[], "Contents");
|
||||
assert_eq!(out, "= Contents =\n");
|
||||
}
|
||||
|
||||
// ===== Links text generation =====
|
||||
|
||||
#[test]
|
||||
fn build_links_text_excludes_current_page() {
|
||||
let pages = vec!["A".to_string(), "Home".to_string(), "B".to_string()];
|
||||
let out = ops::build_links_text(&pages, "Generated Links", Some("Home"));
|
||||
let lines: Vec<&str> = out.lines().collect();
|
||||
assert_eq!(lines[0], "= Generated Links =");
|
||||
assert!(lines.contains(&"- [[A]]"));
|
||||
assert!(lines.contains(&"- [[B]]"));
|
||||
assert!(!lines.iter().any(|l| l.contains("Home")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_links_text_no_excludes_includes_all() {
|
||||
let pages = vec!["A".to_string(), "B".to_string()];
|
||||
let out = ops::build_links_text(&pages, "Generated Links", None);
|
||||
assert!(out.contains("[[A]]"));
|
||||
assert!(out.contains("[[B]]"));
|
||||
}
|
||||
|
||||
// ===== find_section_range =====
|
||||
|
||||
#[test]
|
||||
fn find_section_range_matches_case_insensitive() {
|
||||
let src = "= contents =\n- [[A]]\n- [[B]]\n";
|
||||
let ast = parse(src);
|
||||
let (_, _) = ops::find_section_range(&ast, "Contents").expect("found");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_section_range_misses_when_absent() {
|
||||
let src = "= Welcome =\n";
|
||||
let ast = parse(src);
|
||||
assert!(ops::find_section_range(&ast, "Contents").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_section_range_matches_any_heading_level() {
|
||||
// Section replacement accepts any heading level so
|
||||
// VimwikiGenerateTagLinks stays idempotent when tag sections live
|
||||
// under non-h1 headings (e.g. `== Tag: foo ==`).
|
||||
let src = "== Contents ==\n- [[A]]\n";
|
||||
let ast = parse(src);
|
||||
assert!(ops::find_section_range(&ast, "Contents").is_some());
|
||||
}
|
||||
|
||||
// ===== toc_edit =====
|
||||
|
||||
#[test]
|
||||
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).expect("got an edit");
|
||||
let changes = edit.changes.expect("changes map");
|
||||
let edits = &changes[&uri];
|
||||
assert_eq!(edits.len(), 1);
|
||||
let te = &edits[0];
|
||||
// Insertion at position 0,0 → zero-width range.
|
||||
assert_eq!(te.range.start, te.range.end);
|
||||
assert!(te.new_text.starts_with("= Contents =\n"));
|
||||
assert!(te.new_text.contains("[[#one|One]]"));
|
||||
}
|
||||
|
||||
#[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).expect("got an edit");
|
||||
let changes = edit.changes.expect("changes map");
|
||||
let edits = &changes[&uri];
|
||||
let te = &edits[0];
|
||||
assert!(te.new_text.contains("[[#real|Real]]"));
|
||||
assert!(!te.new_text.contains("Stale"));
|
||||
// The replacement range must start at line 0.
|
||||
assert_eq!(te.range.start.line, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
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).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toc_rebuild_edit_only_acts_when_toc_already_present() {
|
||||
let uri = Url::parse("file:///tmp/page.wiki").unwrap();
|
||||
|
||||
// No existing TOC → rebuild-on-save must not insert one.
|
||||
let without = "= One =\n== Two ==\n";
|
||||
let ast = parse(without);
|
||||
assert!(
|
||||
ops::toc_rebuild_edit(without, &ast, &uri, true).is_none(),
|
||||
"auto_toc should not insert a TOC where none existed"
|
||||
);
|
||||
|
||||
// Existing TOC → rebuild refreshes it.
|
||||
let with = "= Contents =\n- [[#stale|Stale]]\n\n= Real =\n";
|
||||
let ast = parse(with);
|
||||
let edit = ops::toc_rebuild_edit(with, &ast, &uri, true).expect("rebuild edit");
|
||||
let te = &edit.changes.unwrap()[&uri][0];
|
||||
assert!(te.new_text.contains("[[#real|Real]]"));
|
||||
assert!(!te.new_text.contains("Stale"));
|
||||
}
|
||||
|
||||
// ===== links_edit =====
|
||||
|
||||
#[test]
|
||||
fn links_edit_inserts_when_section_absent() {
|
||||
let src = "Hello\n";
|
||||
let ast = parse(src);
|
||||
let uri = Url::parse("file:///tmp/page.wiki").unwrap();
|
||||
let pages = vec!["A".into(), "B".into(), "Home".into()];
|
||||
let edit = ops::links_edit(src, &ast, &uri, "Home", &pages, true).expect("edit");
|
||||
let te = &edit.changes.unwrap()[&uri][0];
|
||||
assert!(te.new_text.contains("[[A]]"));
|
||||
assert!(te.new_text.contains("[[B]]"));
|
||||
assert!(!te.new_text.contains("[[Home]]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn links_edit_replaces_when_section_present() {
|
||||
let src = "= Generated Links =\n- [[Stale]]\n\n= Real =\n";
|
||||
let ast = parse(src);
|
||||
let uri = Url::parse("file:///tmp/page.wiki").unwrap();
|
||||
let pages = vec!["Fresh".into()];
|
||||
let edit = ops::links_edit(src, &ast, &uri, "Home", &pages, true).expect("edit");
|
||||
let te = &edit.changes.unwrap()[&uri][0];
|
||||
assert!(te.new_text.contains("[[Fresh]]"));
|
||||
assert!(!te.new_text.contains("Stale"));
|
||||
assert_eq!(te.range.start.line, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn links_edit_returns_none_for_empty_page_list() {
|
||||
let src = "Hi\n";
|
||||
let ast = parse(src);
|
||||
let uri = Url::parse("file:///tmp/page.wiki").unwrap();
|
||||
assert!(ops::links_edit(src, &ast, &uri, "Home", &[], true).is_none());
|
||||
}
|
||||
|
||||
// ===== find_orphans =====
|
||||
|
||||
#[test]
|
||||
fn find_orphans_lists_pages_with_no_backlinks() {
|
||||
let root = "/tmp/orph";
|
||||
let idx = build_index(
|
||||
root,
|
||||
&[
|
||||
("Home", "[[A]]\n"),
|
||||
("A", "= A =\n"),
|
||||
("B", "= B =\n"), // not linked from anywhere
|
||||
],
|
||||
);
|
||||
let orphans = ops::find_orphans(&idx);
|
||||
let names: Vec<&str> = orphans.iter().map(|o| o.name.as_str()).collect();
|
||||
// Home + B have no backlinks. A is linked from Home.
|
||||
assert!(names.contains(&"Home"));
|
||||
assert!(names.contains(&"B"));
|
||||
assert!(!names.contains(&"A"));
|
||||
}
|
||||
|
||||
// ===== collect_wiki_links (AST walker) =====
|
||||
|
||||
#[test]
|
||||
fn collect_wiki_links_finds_nested() {
|
||||
let src = "= [[A]] =\n*[[B]]* and [[C|desc]]\n- [[D]]\n";
|
||||
let ast = parse(src);
|
||||
let links = nuwiki_lsp::diagnostics::collect_wiki_links(&ast);
|
||||
let paths: Vec<&str> = links
|
||||
.iter()
|
||||
.filter_map(|l| l.target.path.as_deref())
|
||||
.collect();
|
||||
for expected in ["A", "B", "C", "D"] {
|
||||
assert!(paths.contains(&expected), "missing {expected}");
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Source-relative wiki links (vimwiki default) =====
|
||||
|
||||
#[test]
|
||||
fn wiki_link_resolves_source_relative_first() {
|
||||
let root = "/tmp/srcrel1";
|
||||
let idx = build_index(
|
||||
root,
|
||||
&[
|
||||
("tips/index", "[[llm-wiki-pattern|LLM]]\n"),
|
||||
("tips/llm-wiki-pattern", "= LLM =\n"),
|
||||
],
|
||||
);
|
||||
let src = "[[llm-wiki-pattern|LLM]]\n";
|
||||
let ast = parse(src);
|
||||
let diags = diagnostics::link_health(
|
||||
&ast,
|
||||
src,
|
||||
Some(&Url::from_file_path(format!("{root}/tips/index.wiki")).unwrap()),
|
||||
&idx,
|
||||
"tips/index",
|
||||
LinkSeverity::Warning,
|
||||
true,
|
||||
);
|
||||
assert!(
|
||||
diags.is_empty(),
|
||||
"expected no diagnostics, got: {:?}",
|
||||
diags
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wiki_link_falls_back_to_root_relative() {
|
||||
// A source-relative miss should fall through to root-relative — this
|
||||
// keeps `[[posts/foo]]` from `index.wiki` working even though there's
|
||||
// no `posts/posts/foo`.
|
||||
let root = "/tmp/srcrel2";
|
||||
let idx = build_index(
|
||||
root,
|
||||
&[("index", "[[posts/foo|Foo]]\n"), ("posts/foo", "= Foo =\n")],
|
||||
);
|
||||
let src = "[[posts/foo|Foo]]\n";
|
||||
let ast = parse(src);
|
||||
let diags = diagnostics::link_health(
|
||||
&ast,
|
||||
src,
|
||||
Some(&home_uri(root)),
|
||||
&idx,
|
||||
"index",
|
||||
LinkSeverity::Warning,
|
||||
true,
|
||||
);
|
||||
assert!(diags.is_empty(), "got: {:?}", diags);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wiki_link_absolute_skips_source_relative() {
|
||||
// `[[/Page]]` is explicitly root-relative even from a subdir.
|
||||
let root = "/tmp/srcrel3";
|
||||
let idx = build_index(
|
||||
root,
|
||||
&[
|
||||
("tips/index", "[[/RootPage]]\n"),
|
||||
("RootPage", "= Root =\n"),
|
||||
// Intentionally also create tips/RootPage so a source-relative
|
||||
// resolution would (wrongly) prefer it — the absolute form must
|
||||
// ignore source-relative.
|
||||
("tips/RootPage", "= Tips Root =\n"),
|
||||
],
|
||||
);
|
||||
let target = idx
|
||||
.resolve_wiki_path("RootPage", "tips/index", true)
|
||||
.expect("absolute link should resolve");
|
||||
assert!(
|
||||
target.as_str().ends_with("RootPage.wiki"),
|
||||
"expected root RootPage, got {target}",
|
||||
);
|
||||
assert!(
|
||||
!target.as_str().contains("/tips/RootPage"),
|
||||
"absolute link should skip tips/RootPage, got {target}",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wiki_link_parent_dir_collapses() {
|
||||
// `[[../Other]]` from `posts/foo` resolves to `Other` at the wiki root.
|
||||
let root = "/tmp/srcrel4";
|
||||
let idx = build_index(
|
||||
root,
|
||||
&[
|
||||
("posts/foo", "[[../Other|Other]]\n"),
|
||||
("Other", "= Other =\n"),
|
||||
],
|
||||
);
|
||||
let src = "[[../Other|Other]]\n";
|
||||
let ast = parse(src);
|
||||
let diags = diagnostics::link_health(
|
||||
&ast,
|
||||
src,
|
||||
Some(&Url::from_file_path(format!("{root}/posts/foo.wiki")).unwrap()),
|
||||
&idx,
|
||||
"posts/foo",
|
||||
LinkSeverity::Warning,
|
||||
true,
|
||||
);
|
||||
assert!(diags.is_empty(), "got: {:?}", diags);
|
||||
}
|
||||
|
||||
// ===== Anchor matching against raw heading text =====
|
||||
|
||||
#[test]
|
||||
fn anchor_matches_raw_heading_text() {
|
||||
// Users write `[[Page#Some Heading]]` (raw text), the index keys by
|
||||
// slugify form. Slugifying the request before comparison makes both
|
||||
// shapes work.
|
||||
let root = "/tmp/anchor1";
|
||||
let idx = build_index(
|
||||
root,
|
||||
&[
|
||||
("Home", "[[Target#Some Heading]]\n"),
|
||||
("Target", "= Target =\n== Some Heading ==\n"),
|
||||
],
|
||||
);
|
||||
let src = "[[Target#Some Heading]]\n";
|
||||
let ast = parse(src);
|
||||
let diags = diagnostics::link_health(
|
||||
&ast,
|
||||
src,
|
||||
Some(&home_uri(root)),
|
||||
&idx,
|
||||
"Home",
|
||||
LinkSeverity::Warning,
|
||||
true,
|
||||
);
|
||||
assert!(diags.is_empty(), "got: {:?}", diags);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anchor_matches_unicode_heading() {
|
||||
// Real-world content: heading with em dash and accented chars.
|
||||
let root = "/tmp/anchor2";
|
||||
let idx = build_index(
|
||||
root,
|
||||
&[
|
||||
("Home", "[[Target#Homelab — VLANs]]\n"),
|
||||
("Target", "= Target =\n== Homelab — VLANs ==\n"),
|
||||
],
|
||||
);
|
||||
let src = "[[Target#Homelab — VLANs]]\n";
|
||||
let ast = parse(src);
|
||||
let diags = diagnostics::link_health(
|
||||
&ast,
|
||||
src,
|
||||
Some(&home_uri(root)),
|
||||
&idx,
|
||||
"Home",
|
||||
LinkSeverity::Warning,
|
||||
true,
|
||||
);
|
||||
assert!(diags.is_empty(), "got: {:?}", diags);
|
||||
}
|
||||
|
||||
// ===== COMMANDS completeness =====
|
||||
|
||||
#[test]
|
||||
fn commands_list_includes_link_health_entries() {
|
||||
let names: Vec<&str> = nuwiki_lsp::commands::COMMANDS.to_vec();
|
||||
for name in [
|
||||
"nuwiki.toc.generate",
|
||||
"nuwiki.links.generate",
|
||||
"nuwiki.workspace.checkLinks",
|
||||
"nuwiki.workspace.findOrphans",
|
||||
] {
|
||||
assert!(names.contains(&name), "missing: {name}");
|
||||
}
|
||||
}
|
||||
@@ -1,220 +0,0 @@
|
||||
//! Multi-wiki — interwiki resolution + picker commands.
|
||||
//!
|
||||
//! The cross-wiki resolver methods on `Backend` are exercised indirectly
|
||||
//! through `tower_lsp::LspService`'s test harness via a synthetic
|
||||
//! `Backend`. To keep this test suite
|
||||
//! lightweight we drive the *pure* resolution primitives — the parser
|
||||
//! producing `LinkKind::Interwiki` targets, then the `WorkspaceIndex`
|
||||
//! lookup-by-name — and verify the multi-wiki config shape end-to-end.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use nuwiki_core::ast::{InlineNode, LinkKind};
|
||||
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
|
||||
use nuwiki_core::syntax::SyntaxPlugin;
|
||||
use nuwiki_lsp::config::config_from_json;
|
||||
use nuwiki_lsp::index::WorkspaceIndex;
|
||||
use nuwiki_lsp::wiki::{build_wikis, resolve_uri_to_wiki, WikiId};
|
||||
use tower_lsp::lsp_types::Url;
|
||||
|
||||
fn parse(src: &str) -> nuwiki_core::ast::DocumentNode {
|
||||
VimwikiSyntax::new().parse(src)
|
||||
}
|
||||
|
||||
fn first_link(src: &str) -> nuwiki_core::ast::WikiLinkNode {
|
||||
let doc = parse(src);
|
||||
for block in &doc.children {
|
||||
if let nuwiki_core::ast::BlockNode::Paragraph(p) = block {
|
||||
for inline in &p.children {
|
||||
if let InlineNode::WikiLink(w) = inline {
|
||||
return w.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
panic!("no wikilink in: {src}");
|
||||
}
|
||||
|
||||
// ===== Parser-level: `wiki<N>:` / `wn.<Name>:` produce Interwiki =====
|
||||
|
||||
#[test]
|
||||
fn numbered_interwiki_link_parses_to_wiki_index() {
|
||||
let w = first_link("[[wiki1:OtherPage]]\n");
|
||||
assert_eq!(w.target.kind, LinkKind::Interwiki);
|
||||
assert_eq!(w.target.wiki_index, Some(1));
|
||||
assert_eq!(w.target.path.as_deref(), Some("OtherPage"));
|
||||
assert!(w.target.wiki_name.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn named_interwiki_link_parses_to_wiki_name() {
|
||||
let w = first_link("[[wn.work:Project]]\n");
|
||||
assert_eq!(w.target.kind, LinkKind::Interwiki);
|
||||
assert_eq!(w.target.wiki_name.as_deref(), Some("work"));
|
||||
assert_eq!(w.target.path.as_deref(), Some("Project"));
|
||||
assert!(w.target.wiki_index.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_wikilink_is_not_interwiki() {
|
||||
let w = first_link("[[Home]]\n");
|
||||
assert_eq!(w.target.kind, LinkKind::Wiki);
|
||||
assert!(w.target.wiki_index.is_none());
|
||||
assert!(w.target.wiki_name.is_none());
|
||||
}
|
||||
|
||||
// ===== Config: `wikis = [...]` shape =====
|
||||
|
||||
#[test]
|
||||
fn multi_wiki_config_loads_two_entries() {
|
||||
let cfg = config_from_json(serde_json::json!({
|
||||
"wikis": [
|
||||
{ "root": "/tmp/personal", "name": "personal" },
|
||||
{ "root": "/tmp/work", "name": "work" },
|
||||
],
|
||||
}));
|
||||
assert_eq!(cfg.wikis.len(), 2);
|
||||
assert_eq!(cfg.wikis[0].name, "personal");
|
||||
assert_eq!(cfg.wikis[1].name, "work");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v1_0_single_root_still_works_alongside_multi_wiki_shape() {
|
||||
// Single-wiki shorthand is the legacy form; ensure it still desugars.
|
||||
let cfg = config_from_json(serde_json::json!({
|
||||
"wiki_root": "/tmp/legacy",
|
||||
}));
|
||||
assert_eq!(cfg.wikis.len(), 1);
|
||||
assert_eq!(cfg.wikis[0].root, PathBuf::from("/tmp/legacy"));
|
||||
}
|
||||
|
||||
// ===== Wiki aggregate + URI routing =====
|
||||
|
||||
#[test]
|
||||
fn build_wikis_assigns_sequential_ids_starting_at_zero() {
|
||||
let cfg = config_from_json(serde_json::json!({
|
||||
"wikis": [
|
||||
{ "root": "/tmp/p" },
|
||||
{ "root": "/tmp/q" },
|
||||
{ "root": "/tmp/r" },
|
||||
],
|
||||
}));
|
||||
let wikis = build_wikis(&cfg.wikis);
|
||||
assert_eq!(wikis.len(), 3);
|
||||
for (i, w) in wikis.iter().enumerate() {
|
||||
assert_eq!(w.id, WikiId(i as u32));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_uri_to_wiki_routes_to_the_owning_root() {
|
||||
let cfg = config_from_json(serde_json::json!({
|
||||
"wikis": [
|
||||
{ "root": "/tmp/personal" },
|
||||
{ "root": "/tmp/work" },
|
||||
],
|
||||
}));
|
||||
let wikis = build_wikis(&cfg.wikis);
|
||||
let uri = Url::from_file_path("/tmp/work/Project.wiki").unwrap();
|
||||
assert_eq!(resolve_uri_to_wiki(&wikis, &uri), Some(WikiId(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_root_picks_longest_prefix_match() {
|
||||
let cfg = config_from_json(serde_json::json!({
|
||||
"wikis": [
|
||||
{ "root": "/notes" },
|
||||
{ "root": "/notes/sub" },
|
||||
],
|
||||
}));
|
||||
let wikis = build_wikis(&cfg.wikis);
|
||||
let uri = Url::from_file_path("/notes/sub/page.wiki").unwrap();
|
||||
// The nested wiki wins because its root is a longer prefix.
|
||||
assert_eq!(resolve_uri_to_wiki(&wikis, &uri), Some(WikiId(1)));
|
||||
}
|
||||
|
||||
// ===== Cross-wiki resolution (uses WorkspaceIndex per wiki) =====
|
||||
|
||||
/// Verify the cross-wiki resolution semantics:
|
||||
/// `[[wiki1:Page]]` looks up `Page` in the *second* wiki's index, not
|
||||
/// the source wiki's. The pure-data check here mirrors what
|
||||
/// `Backend::resolve_target_uri` does.
|
||||
#[test]
|
||||
fn interwiki_resolution_uses_target_wiki_index() {
|
||||
// Build two wikis with overlapping page names so we can tell whose
|
||||
// index was consulted.
|
||||
let mut work = WorkspaceIndex::new(Some(PathBuf::from("/tmp/work")));
|
||||
work.upsert(
|
||||
Url::from_file_path("/tmp/work/Project.wiki").unwrap(),
|
||||
&parse("= Work Project =\n"),
|
||||
);
|
||||
|
||||
let mut personal = WorkspaceIndex::new(Some(PathBuf::from("/tmp/personal")));
|
||||
personal.upsert(
|
||||
Url::from_file_path("/tmp/personal/Project.wiki").unwrap(),
|
||||
&parse("= Personal Project =\n"),
|
||||
);
|
||||
|
||||
// Simulate the resolver: source is personal, target is `wiki1:Project`
|
||||
// which should hit the work index (assumed to be index 1).
|
||||
let w = first_link("[[wiki1:Project]]\n");
|
||||
assert_eq!(w.target.wiki_index, Some(1));
|
||||
let resolved = work.pages_by_name.get("Project").cloned();
|
||||
assert!(resolved.is_some(), "work wiki should resolve Project");
|
||||
assert!(
|
||||
resolved
|
||||
.unwrap()
|
||||
.as_str()
|
||||
.contains("/tmp/work/Project.wiki"),
|
||||
"must come from the work wiki, not personal"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interwiki_resolution_falls_back_when_wiki_missing() {
|
||||
// Only one wiki registered; `[[wiki2:X]]` references the third
|
||||
// wiki by index — no match expected.
|
||||
let cfg = config_from_json(serde_json::json!({
|
||||
"wikis": [ { "root": "/tmp/only" } ],
|
||||
}));
|
||||
let wikis = build_wikis(&cfg.wikis);
|
||||
// Index 2 is out of range.
|
||||
assert!(wikis.get(2).is_none());
|
||||
}
|
||||
|
||||
// ===== Wiki commands: COMMANDS list completeness =====
|
||||
|
||||
#[test]
|
||||
fn commands_list_includes_wiki_entries() {
|
||||
let names: Vec<&str> = nuwiki_lsp::commands::COMMANDS.to_vec();
|
||||
for name in [
|
||||
"nuwiki.wiki.listAll",
|
||||
"nuwiki.wiki.select",
|
||||
"nuwiki.wiki.openIndex",
|
||||
"nuwiki.wiki.tabOpenIndex",
|
||||
"nuwiki.wiki.gotoPage",
|
||||
] {
|
||||
assert!(names.contains(&name), "missing: {name}");
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Multi-wiki link kind matrix =====
|
||||
|
||||
#[test]
|
||||
fn link_kind_matrix_for_typical_targets() {
|
||||
let cases = &[
|
||||
("[[Home]]", LinkKind::Wiki),
|
||||
("[[/Page]]", LinkKind::Wiki), // root-relative still Wiki
|
||||
("[[//abs/path]]", LinkKind::Local), // filesystem-absolute
|
||||
("[[wiki1:X]]", LinkKind::Interwiki),
|
||||
("[[wn.work:Y]]", LinkKind::Interwiki),
|
||||
("[[file:foo.pdf]]", LinkKind::File),
|
||||
("[[local:foo.pdf]]", LinkKind::Local),
|
||||
("[[diary:2026-05-11]]", LinkKind::Diary),
|
||||
("[[#anchor]]", LinkKind::AnchorOnly),
|
||||
];
|
||||
for (src, expected) in cases {
|
||||
let w = first_link(&format!("{src}\n"));
|
||||
assert_eq!(w.target.kind, *expected, "case: {src}");
|
||||
}
|
||||
}
|
||||
@@ -1,228 +0,0 @@
|
||||
//! Tests for the navigation pieces: WorkspaceIndex queries,
|
||||
//! page-name derivation, anchor slugify, position lookup, and link
|
||||
//! resolution. The async LSP handlers are exercised through these
|
||||
//! helpers; end-to-end JSON-RPC drive lives elsewhere.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use nuwiki_core::ast::{InlineNode, LinkKind};
|
||||
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
|
||||
use nuwiki_core::syntax::SyntaxPlugin;
|
||||
use nuwiki_lsp::index::{page_name_from_uri, slugify, WorkspaceIndex};
|
||||
use nuwiki_lsp::nav::{find_inline_at, lsp_to_byte_pos};
|
||||
use tower_lsp::lsp_types::{Position as LspPosition, Url};
|
||||
|
||||
fn parse(src: &str) -> nuwiki_core::ast::DocumentNode {
|
||||
VimwikiSyntax::new().parse(src)
|
||||
}
|
||||
|
||||
// ===== Slugify =====
|
||||
|
||||
#[test]
|
||||
fn slugify_basic_title() {
|
||||
assert_eq!(slugify("Hello World"), "hello-world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slugify_strips_punctuation_and_collapses_dashes() {
|
||||
assert_eq!(slugify("Hello, World! Again"), "hello-world-again");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slugify_unicode_lowercase() {
|
||||
assert_eq!(slugify("Crème Brûlée"), "crème-brûlée");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slugify_no_trailing_dash() {
|
||||
assert_eq!(slugify("Title — "), "title");
|
||||
}
|
||||
|
||||
// ===== Page name from URI =====
|
||||
|
||||
#[test]
|
||||
fn page_name_relative_to_root() {
|
||||
let root = PathBuf::from("/wiki");
|
||||
let uri = Url::from_file_path("/wiki/dir/Page.wiki").unwrap();
|
||||
assert_eq!(page_name_from_uri(&uri, Some(&root)), "dir/Page");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_name_at_root() {
|
||||
let root = PathBuf::from("/wiki");
|
||||
let uri = Url::from_file_path("/wiki/Index.wiki").unwrap();
|
||||
assert_eq!(page_name_from_uri(&uri, Some(&root)), "Index");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_name_without_root_falls_back_to_stem() {
|
||||
let uri = Url::from_file_path("/tmp/foo.wiki").unwrap();
|
||||
assert_eq!(page_name_from_uri(&uri, None), "foo");
|
||||
}
|
||||
|
||||
// ===== Workspace index =====
|
||||
|
||||
#[test]
|
||||
fn upsert_indexes_headings_and_outgoing_links() {
|
||||
let mut idx = WorkspaceIndex::new(Some(PathBuf::from("/wiki")));
|
||||
let uri = Url::from_file_path("/wiki/Home.wiki").unwrap();
|
||||
let ast = parse("= Home =\n[[Other]] and [[#Section]]\n");
|
||||
idx.upsert(uri.clone(), &ast);
|
||||
|
||||
let page = idx.page(&uri).expect("page indexed");
|
||||
assert_eq!(page.name, "Home");
|
||||
assert_eq!(page.headings.len(), 1);
|
||||
assert_eq!(page.headings[0].title, "Home");
|
||||
assert_eq!(page.headings[0].anchor, "home");
|
||||
assert_eq!(page.outgoing.len(), 2);
|
||||
assert_eq!(page.outgoing[0].target_page.as_deref(), Some("Other"));
|
||||
assert_eq!(page.outgoing[0].kind, LinkKind::Wiki);
|
||||
assert_eq!(page.outgoing[1].kind, LinkKind::AnchorOnly);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upsert_registers_page_by_name_and_records_backlinks() {
|
||||
let mut idx = WorkspaceIndex::new(Some(PathBuf::from("/wiki")));
|
||||
let home = Url::from_file_path("/wiki/Home.wiki").unwrap();
|
||||
let about = Url::from_file_path("/wiki/About.wiki").unwrap();
|
||||
|
||||
idx.upsert(home.clone(), &parse("[[About]]\n"));
|
||||
idx.upsert(about.clone(), &parse("= About =\n"));
|
||||
|
||||
assert_eq!(idx.page_by_name("About").unwrap().uri, about);
|
||||
|
||||
let backlinks = idx.backlinks_for("About");
|
||||
assert_eq!(backlinks.len(), 1);
|
||||
assert_eq!(backlinks[0].source_uri, home);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upsert_replaces_prior_indexing_for_same_uri() {
|
||||
let mut idx = WorkspaceIndex::new(Some(PathBuf::from("/wiki")));
|
||||
let uri = Url::from_file_path("/wiki/Home.wiki").unwrap();
|
||||
idx.upsert(uri.clone(), &parse("[[A]]\n"));
|
||||
assert_eq!(idx.backlinks_for("A").len(), 1);
|
||||
|
||||
// Re-upsert with different content: old backlinks must drop.
|
||||
idx.upsert(uri.clone(), &parse("[[B]]\n"));
|
||||
assert_eq!(idx.backlinks_for("A").len(), 0);
|
||||
assert_eq!(idx.backlinks_for("B").len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_drops_page_and_backlinks() {
|
||||
let mut idx = WorkspaceIndex::new(Some(PathBuf::from("/wiki")));
|
||||
let home = Url::from_file_path("/wiki/Home.wiki").unwrap();
|
||||
let about = Url::from_file_path("/wiki/About.wiki").unwrap();
|
||||
idx.upsert(home.clone(), &parse("[[About]]\n"));
|
||||
idx.upsert(about.clone(), &parse("= About =\n"));
|
||||
|
||||
idx.remove(&home);
|
||||
assert!(idx.page(&home).is_none());
|
||||
assert!(idx.backlinks_for("About").is_empty());
|
||||
// The target page is untouched.
|
||||
assert!(idx.page(&about).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_wiki_link_to_uri() {
|
||||
let mut idx = WorkspaceIndex::new(Some(PathBuf::from("/wiki")));
|
||||
let about = Url::from_file_path("/wiki/About.wiki").unwrap();
|
||||
idx.upsert(about.clone(), &parse("= About =\n"));
|
||||
|
||||
let target = nuwiki_core::ast::LinkTarget {
|
||||
kind: LinkKind::Wiki,
|
||||
path: Some("About".into()),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(idx.resolve(&target, "Home"), Some(&about));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_anchor_only_link_returns_source_page() {
|
||||
let mut idx = WorkspaceIndex::new(Some(PathBuf::from("/wiki")));
|
||||
let home = Url::from_file_path("/wiki/Home.wiki").unwrap();
|
||||
idx.upsert(home.clone(), &parse("= H =\n[[#Section]]\n"));
|
||||
|
||||
let target = nuwiki_core::ast::LinkTarget {
|
||||
kind: LinkKind::AnchorOnly,
|
||||
anchor: Some("section".into()),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(idx.resolve(&target, "Home"), Some(&home));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_names_lists_indexed_pages_sorted() {
|
||||
let mut idx = WorkspaceIndex::new(Some(PathBuf::from("/wiki")));
|
||||
for name in ["Charlie", "Alpha", "Bravo"] {
|
||||
let uri = Url::from_file_path(format!("/wiki/{name}.wiki")).unwrap();
|
||||
idx.upsert(uri, &parse(&format!("= {name} =\n")));
|
||||
}
|
||||
let names = idx.page_names();
|
||||
assert_eq!(names, vec!["Alpha", "Bravo", "Charlie"]);
|
||||
}
|
||||
|
||||
// ===== Position conversion =====
|
||||
|
||||
#[test]
|
||||
fn lsp_to_byte_pos_passthrough_in_utf8() {
|
||||
let p = LspPosition {
|
||||
line: 2,
|
||||
character: 5,
|
||||
};
|
||||
let (line, col) = lsp_to_byte_pos(p, "ignored", true);
|
||||
assert_eq!((line, col), (2, 5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lsp_to_byte_pos_converts_from_utf16() {
|
||||
// 'é' is one UTF-16 code unit, two UTF-8 bytes. Char "2" after 'é':
|
||||
// UTF-16 col 2 → byte col 3.
|
||||
let text = "héllo\n";
|
||||
let p = LspPosition {
|
||||
line: 0,
|
||||
character: 2,
|
||||
};
|
||||
let (line, col) = lsp_to_byte_pos(p, text, false);
|
||||
assert_eq!(line, 0);
|
||||
assert_eq!(col, 3);
|
||||
}
|
||||
|
||||
// ===== Position lookup =====
|
||||
|
||||
#[test]
|
||||
fn find_inline_at_returns_wikilink_under_cursor() {
|
||||
let src = "see [[Other]] now\n";
|
||||
let doc = parse(src);
|
||||
// Cursor inside "Other" — byte col 6 falls inside "[[Other]]" (which
|
||||
// starts at byte 4 and ends at byte 13).
|
||||
let node = find_inline_at(&doc, 0, 6).expect("wikilink at cursor");
|
||||
assert!(matches!(node, InlineNode::WikiLink(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_inline_at_returns_none_for_plain_text() {
|
||||
let src = "just text\n";
|
||||
let doc = parse(src);
|
||||
let node = find_inline_at(&doc, 0, 3);
|
||||
// "just text" → Text node, which is what we get back from the inner
|
||||
// walk (not None). Verify it's a Text.
|
||||
assert!(matches!(node, Some(InlineNode::Text(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_inline_at_descends_into_inline_containers() {
|
||||
let src = "*bold text*\n";
|
||||
let doc = parse(src);
|
||||
// Inside "bold" — should return the inner Text, not the wrapping Bold.
|
||||
let node = find_inline_at(&doc, 0, 3).expect("hit");
|
||||
assert!(matches!(node, InlineNode::Text(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_inline_at_returns_none_past_eol() {
|
||||
let src = "short\n";
|
||||
let doc = parse(src);
|
||||
assert!(find_inline_at(&doc, 0, 999).is_none());
|
||||
}
|
||||
@@ -1,312 +0,0 @@
|
||||
//! Tests for the semantic-token builder, encoder, range filter, and
|
||||
//! UTF-8/UTF-16 conversion.
|
||||
|
||||
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
|
||||
use nuwiki_core::syntax::SyntaxPlugin;
|
||||
use nuwiki_lsp::semantic_tokens::{
|
||||
build_data, collect_tokens, encode, legend, LineIndex, RawToken, MOD_CENTERED, MOD_LEVEL1,
|
||||
MOD_LEVEL2, TOKEN_BOLD, TOKEN_CODE, TOKEN_CODE_BLOCK, TOKEN_COMMENT, TOKEN_DEFINITION_TERM,
|
||||
TOKEN_HEADING, TOKEN_ITALIC, TOKEN_KEYWORD, TOKEN_MATH_BLOCK, TOKEN_TRANSCLUSION,
|
||||
TOKEN_TYPE_NAMES, TOKEN_URL, TOKEN_WIKILINK,
|
||||
};
|
||||
use tower_lsp::lsp_types::{Position, Range};
|
||||
|
||||
fn parse(src: &str) -> nuwiki_core::ast::DocumentNode {
|
||||
VimwikiSyntax::new().parse(src)
|
||||
}
|
||||
|
||||
fn first_with_kind(tokens: &[RawToken], kind: u32) -> Option<&RawToken> {
|
||||
tokens.iter().find(|t| t.kind == kind)
|
||||
}
|
||||
|
||||
// ===== Legend =====
|
||||
|
||||
#[test]
|
||||
fn legend_lists_all_custom_token_types() {
|
||||
let l = legend();
|
||||
assert_eq!(l.token_types.len(), TOKEN_TYPE_NAMES.len());
|
||||
let names: Vec<String> = l
|
||||
.token_types
|
||||
.iter()
|
||||
.map(|t| t.as_str().to_owned())
|
||||
.collect();
|
||||
assert!(names.contains(&"vimwikiHeading".to_string()));
|
||||
assert!(names.contains(&"vimwikiWikilink".to_string()));
|
||||
assert!(names.contains(&"vimwikiTransclusion".to_string()));
|
||||
}
|
||||
|
||||
// ===== Per-construct emission =====
|
||||
|
||||
#[test]
|
||||
fn heading_emits_one_token_with_level_modifier() {
|
||||
let doc = parse("== Hi ==\n");
|
||||
let toks = collect_tokens(&doc, "== Hi ==\n");
|
||||
let h = first_with_kind(&toks, TOKEN_HEADING).expect("heading token");
|
||||
assert_eq!(h.mods & MOD_LEVEL2, MOD_LEVEL2);
|
||||
assert_eq!(h.mods & MOD_CENTERED, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn centered_heading_carries_centered_modifier() {
|
||||
let src = " = Title =\n";
|
||||
let doc = parse(src);
|
||||
let toks = collect_tokens(&doc, src);
|
||||
let h = first_with_kind(&toks, TOKEN_HEADING).expect("heading token");
|
||||
assert_eq!(h.mods & MOD_LEVEL1, MOD_LEVEL1);
|
||||
assert_eq!(h.mods & MOD_CENTERED, MOD_CENTERED);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heading_does_not_emit_inner_inline_tokens() {
|
||||
// Headings render as a single styled span, so bold inside is shadowed.
|
||||
let src = "= Hi *bold* =\n";
|
||||
let doc = parse(src);
|
||||
let toks = collect_tokens(&doc, src);
|
||||
assert!(toks.iter().any(|t| t.kind == TOKEN_HEADING));
|
||||
assert!(
|
||||
!toks.iter().any(|t| t.kind == TOKEN_BOLD),
|
||||
"should not have emitted Bold inside a Heading"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraph_emits_inline_tokens_only() {
|
||||
let src = "see *bold* and `code`\n";
|
||||
let doc = parse(src);
|
||||
let toks = collect_tokens(&doc, src);
|
||||
assert!(toks.iter().any(|t| t.kind == TOKEN_BOLD));
|
||||
assert!(toks.iter().any(|t| t.kind == TOKEN_CODE));
|
||||
assert!(!toks.iter().any(|t| t.kind == TOKEN_HEADING));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn italic_token_for_underscored_run() {
|
||||
let src = "_emphasis_\n";
|
||||
let doc = parse(src);
|
||||
let toks = collect_tokens(&doc, src);
|
||||
assert!(toks.iter().any(|t| t.kind == TOKEN_ITALIC));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyword_token_for_each_keyword() {
|
||||
let src = "TODO write tests, FIXME later\n";
|
||||
let doc = parse(src);
|
||||
let toks = collect_tokens(&doc, src);
|
||||
let kw_count = toks.iter().filter(|t| t.kind == TOKEN_KEYWORD).count();
|
||||
assert_eq!(kw_count, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wikilink_and_external_link_get_different_types() {
|
||||
let src = "[[Page]] and [[https://example.com|docs]]\n";
|
||||
let doc = parse(src);
|
||||
let toks = collect_tokens(&doc, src);
|
||||
assert!(toks.iter().any(|t| t.kind == TOKEN_WIKILINK));
|
||||
assert!(
|
||||
toks.iter()
|
||||
.any(|t| t.kind == nuwiki_lsp::semantic_tokens::TOKEN_EXTERNAL_LINK),
|
||||
"URL inside [[...]] should be vimwikiExternalLink, got {toks:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_url_emits_url_token() {
|
||||
let src = "see https://example.com here\n";
|
||||
let doc = parse(src);
|
||||
let toks = collect_tokens(&doc, src);
|
||||
assert!(toks.iter().any(|t| t.kind == TOKEN_URL));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transclusion_emits_transclusion_token() {
|
||||
let src = "{{img.png|alt}}\n";
|
||||
let doc = parse(src);
|
||||
let toks = collect_tokens(&doc, src);
|
||||
assert!(toks.iter().any(|t| t.kind == TOKEN_TRANSCLUSION));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comment_token_for_single_line_comment() {
|
||||
let src = "%% hidden\nstuff\n";
|
||||
let doc = parse(src);
|
||||
let toks = collect_tokens(&doc, src);
|
||||
assert!(toks.iter().any(|t| t.kind == TOKEN_COMMENT));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn definition_term_token() {
|
||||
let src = "Term:: Definition\n";
|
||||
let doc = parse(src);
|
||||
let toks = collect_tokens(&doc, src);
|
||||
assert!(toks.iter().any(|t| t.kind == TOKEN_DEFINITION_TERM));
|
||||
}
|
||||
|
||||
// ===== Multi-line fences =====
|
||||
|
||||
#[test]
|
||||
fn preformatted_block_splits_into_one_token_per_line() {
|
||||
let src = "{{{rust\nlet x = 1;\nlet y = 2;\n}}}\n";
|
||||
let doc = parse(src);
|
||||
let toks = collect_tokens(&doc, src);
|
||||
let code_tokens: Vec<&RawToken> = toks.iter().filter(|t| t.kind == TOKEN_CODE_BLOCK).collect();
|
||||
// Spans line 0 (open fence) through line 3 (close fence) = 4 lines, but
|
||||
// empty trailing slices are dropped, so we expect 4 line tokens.
|
||||
assert!(
|
||||
code_tokens.len() >= 3,
|
||||
"expected multi-line split, got {code_tokens:?}"
|
||||
);
|
||||
// No token should cross a line boundary.
|
||||
for t in &code_tokens {
|
||||
assert!(t.byte_len > 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn math_block_splits_into_one_token_per_line() {
|
||||
let src = "{{$\nx=1\ny=2\n}}$\n";
|
||||
let doc = parse(src);
|
||||
let toks = collect_tokens(&doc, src);
|
||||
let math_tokens: Vec<&RawToken> = toks.iter().filter(|t| t.kind == TOKEN_MATH_BLOCK).collect();
|
||||
assert!(math_tokens.len() >= 3);
|
||||
}
|
||||
|
||||
// ===== Sorting =====
|
||||
|
||||
#[test]
|
||||
fn tokens_are_sorted_by_line_then_column() {
|
||||
let src = "= H =\n*bold* and _italic_\n";
|
||||
let doc = parse(src);
|
||||
let toks = collect_tokens(&doc, src);
|
||||
for w in toks.windows(2) {
|
||||
let a = (w[0].line, w[0].byte_col);
|
||||
let b = (w[1].line, w[1].byte_col);
|
||||
assert!(a <= b, "tokens not sorted: {a:?} then {b:?}");
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Encoding =====
|
||||
|
||||
#[test]
|
||||
fn encode_produces_correct_delta_quintuples() {
|
||||
let src = "= H =\n*bold*\n";
|
||||
let raws = vec![
|
||||
RawToken {
|
||||
line: 0,
|
||||
byte_col: 0,
|
||||
byte_len: 5,
|
||||
kind: TOKEN_HEADING,
|
||||
mods: MOD_LEVEL1,
|
||||
},
|
||||
RawToken {
|
||||
line: 1,
|
||||
byte_col: 0,
|
||||
byte_len: 6,
|
||||
kind: TOKEN_BOLD,
|
||||
mods: 0,
|
||||
},
|
||||
];
|
||||
let idx = LineIndex::new(src);
|
||||
let data = encode(&raws, src, &idx, true);
|
||||
assert_eq!(data.len(), 10);
|
||||
// First token: delta_line=0, delta_col=0, len=5, type=HEADING, mods=LEVEL1
|
||||
assert_eq!(&data[0..5], &[0, 0, 5, TOKEN_HEADING, MOD_LEVEL1]);
|
||||
// Second token: delta_line=1 (new line), delta_col=0 (absolute), len=6, type=BOLD, mods=0
|
||||
assert_eq!(&data[5..10], &[1, 0, 6, TOKEN_BOLD, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encode_uses_relative_column_within_same_line() {
|
||||
let src = "abc def ghi\n";
|
||||
let raws = vec![
|
||||
RawToken {
|
||||
line: 0,
|
||||
byte_col: 0,
|
||||
byte_len: 3,
|
||||
kind: TOKEN_BOLD,
|
||||
mods: 0,
|
||||
},
|
||||
RawToken {
|
||||
line: 0,
|
||||
byte_col: 4,
|
||||
byte_len: 3,
|
||||
kind: TOKEN_ITALIC,
|
||||
mods: 0,
|
||||
},
|
||||
RawToken {
|
||||
line: 0,
|
||||
byte_col: 8,
|
||||
byte_len: 3,
|
||||
kind: TOKEN_CODE,
|
||||
mods: 0,
|
||||
},
|
||||
];
|
||||
let idx = LineIndex::new(src);
|
||||
let data = encode(&raws, src, &idx, true);
|
||||
assert_eq!(&data[0..5], &[0, 0, 3, TOKEN_BOLD, 0]);
|
||||
// Second: delta_col = 4 - 0 = 4
|
||||
assert_eq!(&data[5..10], &[0, 4, 3, TOKEN_ITALIC, 0]);
|
||||
// Third: delta_col = 8 - 4 = 4
|
||||
assert_eq!(&data[10..15], &[0, 4, 3, TOKEN_CODE, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encode_converts_to_utf16_for_multibyte_chars() {
|
||||
// "_é_" — italic delimiters around é. Span covers 4 bytes (1 + 2 + 1)
|
||||
// but 3 UTF-16 code units.
|
||||
let src = "_é_\n";
|
||||
let raws = vec![RawToken {
|
||||
line: 0,
|
||||
byte_col: 0,
|
||||
byte_len: 4,
|
||||
kind: TOKEN_ITALIC,
|
||||
mods: 0,
|
||||
}];
|
||||
let idx = LineIndex::new(src);
|
||||
let data = encode(&raws, src, &idx, false);
|
||||
// length should be in UTF-16 code units (3), not bytes (4).
|
||||
assert_eq!(data[2], 3);
|
||||
}
|
||||
|
||||
// ===== Range filter =====
|
||||
|
||||
#[test]
|
||||
fn range_filter_keeps_only_overlapping_tokens() {
|
||||
let src = "= L1 =\n= L2 =\n= L3 =\n";
|
||||
let doc = parse(src);
|
||||
// Restrict to line 1 only.
|
||||
let range = Range {
|
||||
start: Position {
|
||||
line: 1,
|
||||
character: 0,
|
||||
},
|
||||
end: Position {
|
||||
line: 2,
|
||||
character: 0,
|
||||
},
|
||||
};
|
||||
let data = build_data(&doc, src, true, Some(range));
|
||||
// Expect exactly 1 heading token (the L2 one).
|
||||
assert_eq!(data.len(), 5, "got {data:?}");
|
||||
// Verify it's on line 1 (delta_line = 1 from prev_line = 0).
|
||||
assert_eq!(data[0], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_document_produces_empty_token_stream() {
|
||||
let doc = parse("");
|
||||
let data = build_data(&doc, "", true, None);
|
||||
assert!(data.is_empty());
|
||||
}
|
||||
|
||||
// ===== End-to-end through builder =====
|
||||
|
||||
#[test]
|
||||
fn build_data_matches_collect_then_encode() {
|
||||
let src = "= H1 =\n_em_ TODO\n";
|
||||
let doc = parse(src);
|
||||
let direct = build_data(&doc, src, true, None);
|
||||
let raws = collect_tokens(&doc, src);
|
||||
let idx = LineIndex::new(src);
|
||||
let manual = encode(&raws, src, &idx, true);
|
||||
assert_eq!(direct, manual);
|
||||
}
|
||||
+57
-86
@@ -4,41 +4,12 @@
|
||||
|
||||
nuwiki is a vimwiki-compatible Vim/Neovim plugin backed by a Rust language server. It provides full vimwiki syntax support while keeping the original file format, keymaps, and command surface intact. The substantive work happens in a Rust LSP daemon — Vim and Neovim are thin client layers that wire up keystrokes and display results.
|
||||
|
||||
The Rust LSP server lives in the separate [nuwiki-rs](https://code.gfran.co/gffranco/nuwiki-rs) repository. This plugin repo is the Vim/Neovim client layer and is distributed independently from the server binary.
|
||||
|
||||
## Repository Structure
|
||||
|
||||
```
|
||||
nuwiki/
|
||||
├── Cargo.toml # Rust workspace root
|
||||
├── Cargo.lock
|
||||
├── crates/
|
||||
│ ├── nuwiki-ls/ # Binary crate — thin main.rs, starts stdio LSP server
|
||||
│ │ ├── Cargo.toml
|
||||
│ │ └── src/main.rs
|
||||
│ │
|
||||
│ ├── nuwiki-core/ # Library crate — parser, AST, renderer (no editor deps)
|
||||
│ │ ├── Cargo.toml
|
||||
│ │ └── src/
|
||||
│ │ ├── lib.rs
|
||||
│ │ ├── syntax/ # Syntax plugins (vimwiki, future markdown)
|
||||
│ │ │ ├── mod.rs
|
||||
│ │ │ ├── registry.rs
|
||||
│ │ │ └── vimwiki/
|
||||
│ │ │ ├── mod.rs
|
||||
│ │ │ ├── lexer.rs
|
||||
│ │ │ └── parser.rs
|
||||
│ │ ├── ast/ # Abstract Syntax Tree node definitions
|
||||
│ │ │ ├── mod.rs
|
||||
│ │ │ ├── block.rs
|
||||
│ │ │ ├── inline.rs
|
||||
│ │ │ └── link.rs
|
||||
│ │ └── render/ # Renderers (HTML, etc.)
|
||||
│ │ ├── mod.rs
|
||||
│ │ └── html.rs
|
||||
│ │
|
||||
│ └── nuwiki-lsp/ # Library crate — LSP protocol bridge
|
||||
│ ├── Cargo.toml
|
||||
│ └── src/lib.rs
|
||||
│
|
||||
├── plugin/ # Universal Vim/Neovim entry point
|
||||
│ └── nuwiki.vim
|
||||
│
|
||||
@@ -47,7 +18,7 @@ nuwiki/
|
||||
│ ├── init.lua
|
||||
│ ├── config.lua
|
||||
│ ├── lsp.lua
|
||||
│ └── install.lua # Binary download / build-from-source logic
|
||||
│ └── install.lua # Binary download logic
|
||||
│
|
||||
├── autoload/ # Lazy-loaded VimL (Vim compat layer)
|
||||
│ └── nuwiki/
|
||||
@@ -88,30 +59,29 @@ nuwiki/
|
||||
│
|
||||
└── .gitea/
|
||||
└── workflows/
|
||||
├── ci.yaml # Lint, test, fmt check on every push/PR
|
||||
└── release.yaml # Cross-compile + release on v* tag
|
||||
├── ci.yaml # Editor (Vim/Neovim) harnesses on every push/PR
|
||||
└── release.yaml # Dispatch → stamp version, tag, Gitea release (no build)
|
||||
```
|
||||
|
||||
## Key Crates
|
||||
(The Rust server's *binary* build/cross-compile workflow lives in the separate
|
||||
[nuwiki-rs](https://code.gfran.co/gffranco/nuwiki-rs) repository; this repo's
|
||||
release just tags the plugin.)
|
||||
|
||||
1. **nuwiki-core**: Contains the parser, lexer, AST, and renderer. This is the pure-Rust core with no editor dependencies.
|
||||
2. **nuwiki-lsp**: Bridges the core to the LSP protocol using tower-lsp. Handles LSP requests/responses and manages the document store.
|
||||
3. **nuwiki-ls**: Binary crate that starts the stdio LSP server. Very thin — just initializes the LSP server and runs it.
|
||||
## Server Component
|
||||
|
||||
The Rust LSP server (`nuwiki-ls`) lives in the separate [nuwiki-rs](https://code.gfran.co/gffranco/nuwiki-rs) repository. Pre-built binaries are downloaded automatically by the plugin's `install()` function. To build from source:
|
||||
|
||||
```bash
|
||||
git clone https://code.gfran.co/gffranco/nuwiki-rs
|
||||
cd nuwiki-rs
|
||||
cargo build --release -p nuwiki-ls
|
||||
```
|
||||
|
||||
## Build & Installation
|
||||
|
||||
### Prerequisites
|
||||
- Rust toolchain (1.83+ stable)
|
||||
- Cargo
|
||||
- For editor integration: Vim 9+ or Neovim 0.5+ with an LSP client (vim-lsp recommended for Vim, built-in for Neovim 0.11+)
|
||||
|
||||
### Development Build
|
||||
```bash
|
||||
# From the repository root
|
||||
cargo build --release -p nuwiki-ls # Builds the LSP binary
|
||||
```
|
||||
|
||||
The binary will be placed at `target/release/nuwiki-ls`.
|
||||
- The `nuwiki-ls` binary is downloaded automatically; no Rust toolchain required unless building from source
|
||||
|
||||
### Installation via Plugin Managers
|
||||
|
||||
@@ -143,37 +113,34 @@ call dein#add('gffranco/nuwiki', {
|
||||
```bash
|
||||
git clone https://code.gfran.co/gffranco/nuwiki ~/.vim/pack/gffranco/start/nuwiki
|
||||
cd ~/.vim/pack/gffranco/start/nuwiki
|
||||
cargo build --release -p nuwiki-ls
|
||||
mkdir -p bin && ln -s ../target/release/nuwiki-ls bin/nuwiki-ls
|
||||
vim -e -s -c "source scripts/download_bin.vim" -c "q"
|
||||
```
|
||||
|
||||
Plain Vim users also need an LSP client — vim-lsp is recommended.
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### Testing
|
||||
```bash
|
||||
# Run all tests (workspace)
|
||||
cargo test --workspace
|
||||
### Testing (Editor Layer)
|
||||
|
||||
# Run tests for a specific crate
|
||||
cargo test -p nuwiki-core
|
||||
cargo test -p nuwiki-lsp
|
||||
cargo test -p nuwiki-ls
|
||||
```bash
|
||||
# Run editor tests (shell + Lua harnesses in development/tests/)
|
||||
./development/tests/test-keymaps.sh
|
||||
./development/tests/test-calendar.sh
|
||||
```
|
||||
|
||||
### Linting & Formatting
|
||||
```bash
|
||||
# Check formatting
|
||||
cargo fmt -- --check
|
||||
For Rust-level tests, see the [nuwiki-rs](https://code.gfran.co/gffranco/nuwiki-rs) repository:
|
||||
|
||||
# Run Clippy
|
||||
```bash
|
||||
# In the nuwiki-rs repo
|
||||
cargo test --workspace
|
||||
cargo clippy --workspace -- -D warnings
|
||||
```
|
||||
|
||||
### Running the LSP Server Manually (for debugging)
|
||||
|
||||
Build from [nuwiki-rs](https://code.gfran.co/gffranco/nuwiki-rs) and run:
|
||||
|
||||
```bash
|
||||
# Build and run the binary
|
||||
cargo run -p nuwiki-ls -- --help
|
||||
```
|
||||
|
||||
@@ -210,44 +177,48 @@ This verifies:
|
||||
|
||||
## Important Notes
|
||||
|
||||
- The core library (`nuwiki-core`) is completely editor-agnostic and can be tested independently.
|
||||
- The LSP layer (`nuwiki-lsp`) depends only on the core and tower-lsp.
|
||||
- The binary crate (`nuwiki-ls`) is intentionally thin — it just initializes and runs the LSP server.
|
||||
- The Rust LSP server is in the separate [nuwiki-rs](https://code.gfran.co/gffranco/nuwiki-rs) repository.
|
||||
- This plugin repo contains only the editor client layer (VimL/Lua).
|
||||
- Editor layers (VimL/Lua) contain zero logic — they are pure wiring only.
|
||||
- All syntax-specific code lives in `nuwiki-core/src/syntax/<syntax>/` making it easy to add new syntaxes (e.g., markdown) in the future.
|
||||
- The project uses a workspace Cargo.toml with resolver v2 (edition 2021).
|
||||
- All syntax-specific code lives in the Rust LSP server's `nuwiki-core` crate.
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. Clone the repository:
|
||||
1. Clone this plugin repository:
|
||||
```bash
|
||||
git clone https://code.gfran.co/gffranco/nuwiki
|
||||
cd nuwiki
|
||||
```
|
||||
|
||||
2. Build the LSP binary:
|
||||
```bash
|
||||
cargo build --release -p nuwiki-ls
|
||||
```
|
||||
2. Install the plugin in your Neovim/Vim configuration using your preferred plugin manager (see above examples). The `nuwiki-ls` binary will be downloaded automatically.
|
||||
|
||||
3. Set up a test wiki directory:
|
||||
```bash
|
||||
mkdir -p ~/test-wiki
|
||||
echo "# Test Wiki" > ~/test-wiki/index.wiki
|
||||
```
|
||||
|
||||
4. Install the plugin in your Neovim/Vim configuration using your preferred plugin manager (see above examples).
|
||||
|
||||
5. Open a .wiki file and verify:
|
||||
3. Open a .wiki file and verify:
|
||||
- Syntax highlighting works
|
||||
- `:VimwikiIndex` opens the index page
|
||||
- LSP features (go-to-definition, hover, completions) work via `:checkhealth nuwiki`
|
||||
|
||||
To modify the LSP server itself, clone the [nuwiki-rs](https://code.gfran.co/gffranco/nuwiki-rs) repository and build the binary from source.
|
||||
|
||||
## CI/CD
|
||||
|
||||
The project uses Gitea Actions:
|
||||
- CI (`.gitea/workflows/ci.yaml`): Runs on every push/PR — checks formatting, Clippy, and runs tests.
|
||||
- Release (`.gitea/workflows/release.yaml`): Triggers on `v*` tag — cross-compiles for Linux targets and creates a Gitea release.
|
||||
This repo (editor client) uses Gitea Actions:
|
||||
- CI (`.gitea/workflows/ci.yaml`): Runs the editor harnesses on every push/PR.
|
||||
- Release (`.gitea/workflows/release.yaml`): `workflow_dispatch` with a `version` input — stamps `g:nuwiki_version`, tags `vX.Y.Z`, and publishes a Gitea release. No binary build (the plugin ships none).
|
||||
|
||||
The Rust LSP server lives in [nuwiki-rs](https://code.gfran.co/gffranco/nuwiki-rs) which has its own release workflow — it cross-compiles `nuwiki-ls` for Linux targets and publishes the downloadable binaries the plugin fetches.
|
||||
|
||||
### Releasing the plugin
|
||||
|
||||
Releases are cut from the Actions UI — **Release** workflow
|
||||
(`.gitea/workflows/release.yaml`) → **Run workflow** → enter the version
|
||||
(e.g. `0.5.1`). It stamps `g:nuwiki_version` in `plugin/nuwiki.vim`,
|
||||
sanity-checks that the plugin still sources, commits `chore(release): X.Y.Z`,
|
||||
pushes the `vX.Y.Z` tag, and publishes a Gitea release entry. The plugin ships
|
||||
no binary, so the release carries no assets by design.
|
||||
|
||||
The plugin and the `nuwiki-ls` server version independently; the binary is
|
||||
fetched from nuwiki-rs's `latest` release at install time. To do it by hand
|
||||
instead: bump `g:nuwiki_version`, commit, then `git tag vX.Y.Z && git push --tags`.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
+19
-46
@@ -6,16 +6,12 @@ For day-one setup see [ONBOARDING.md](ONBOARDING.md); for user-facing help see
|
||||
|
||||
nuwiki is a vimwiki-compatible Vim/Neovim plugin backed by a Rust language
|
||||
server. The editor layers are thin clients; all parsing, navigation, and
|
||||
editing logic lives in the Rust crates and is reached over LSP.
|
||||
editing logic lives in the [nuwiki-rs](https://code.gfran.co/gffranco/nuwiki-rs)
|
||||
repository and is reached over LSP.
|
||||
|
||||
| Property | Value |
|
||||
|---|---|
|
||||
| License | Dual MIT / Apache-2.0 |
|
||||
| MSRV | Rust 1.83 |
|
||||
| Edition / resolver | 2021 / v2 |
|
||||
| Repo | `https://code.gfran.co/gffranco/nuwiki` |
|
||||
| VCS / CI | Gitea + Gitea Actions |
|
||||
| LSP library | `tower-lsp` (tokio, stdio transport) |
|
||||
| Min editors | Neovim 0.11+, Vim 9.1+ |
|
||||
|
||||
---
|
||||
@@ -28,42 +24,21 @@ editing logic lives in the Rust crates and is reached over LSP.
|
||||
Editor client (VimL / Lua) — pure wiring, no logic
|
||||
│ LSP over stdio
|
||||
▼
|
||||
nuwiki-lsp — protocol bridge, executeCommand, document store, config
|
||||
│
|
||||
▼
|
||||
nuwiki-core — lexer → parser → AST → renderer (no editor deps)
|
||||
nuwiki-ls (nuwiki-rs) — Rust LSP server: protocol bridge, executeCommand,
|
||||
document store, config, parser, renderer
|
||||
```
|
||||
|
||||
### Crate dependency rules
|
||||
> The Rust LSP server is in the separate [nuwiki-rs](https://code.gfran.co/gffranco/nuwiki-rs)
|
||||
> repository. This plugin repo is the Vim/Neovim client layer only.
|
||||
>
|
||||
> The architecture table below is for reference — the Rust source lives in nuwiki-rs.
|
||||
|
||||
```
|
||||
nuwiki-ls → nuwiki-lsp → nuwiki-core
|
||||
```
|
||||
|
||||
- `nuwiki-core` never depends on `nuwiki-lsp` / `nuwiki-ls`.
|
||||
- `nuwiki-lsp` never depends on `nuwiki-ls`.
|
||||
- The VimL and Lua layers contain no logic — every command body issues a
|
||||
`workspace/executeCommand` or a built-in LSP request.
|
||||
|
||||
### Repository layout
|
||||
|
||||
```
|
||||
crates/
|
||||
nuwiki-core/ # parser, AST, renderer — editor-independent
|
||||
src/
|
||||
ast/ # block.rs inline.rs link.rs span.rs visit.rs
|
||||
syntax/ # registry.rs + vimwiki/{lexer,parser}.rs
|
||||
render/ # html.rs
|
||||
date.rs # diary period math (no chrono dependency)
|
||||
nuwiki-lsp/ # LSP backend
|
||||
src/
|
||||
lib.rs # Backend, capabilities, textDocument handlers
|
||||
commands.rs # executeCommand dispatcher + pure edit ops
|
||||
config.rs # Config / WikiConfig / HtmlConfig
|
||||
diagnostics.rs index.rs nav.rs rename.rs semantic_tokens.rs
|
||||
diary.rs export.rs edits.rs folding.rs wiki.rs
|
||||
nuwiki-ls/ # thin binary: starts the stdio server
|
||||
|
||||
plugin/nuwiki.vim # universal entry point (detects Vim vs Neovim)
|
||||
lua/nuwiki/ # Neovim layer: init, config, lsp, keymaps, commands,
|
||||
# folding, install
|
||||
@@ -72,12 +47,15 @@ ftdetect/ ftplugin/ syntax/ # filetype detection, buffer setup, fallback HL
|
||||
doc/nuwiki.txt # :help
|
||||
scripts/download_bin.vim # plugin-runtime binary download (build hooks)
|
||||
development/ # dev-only tooling + this document (not shipped)
|
||||
.gitea/workflows/ # ci.yaml, release.yaml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. nuwiki-core
|
||||
## 2. nuwiki-core (nuwiki-rs)
|
||||
|
||||
> This section documents the Rust server's internals — the source lives in
|
||||
> the [nuwiki-rs](https://code.gfran.co/gffranco/nuwiki-rs) repository. Kept
|
||||
> here as a technical reference for the LSP protocol contract.
|
||||
|
||||
### AST
|
||||
|
||||
@@ -199,9 +177,8 @@ raw and external URLs are never diagnosed.
|
||||
Pre-built binaries are published as Gitea release assets
|
||||
(`nuwiki-ls-{version}-{target}.tar.gz`) and fetched at install time by
|
||||
`lua/nuwiki/install.lua` (Neovim) or `scripts/download_bin.vim` (Vim build
|
||||
hooks), installed to `{plugin_dir}/bin/nuwiki-ls`. Falls back to
|
||||
`cargo build --release` when download fails or
|
||||
`g:nuwiki_build_from_source = 1`.
|
||||
hooks), installed to `{plugin_dir}/bin/nuwiki-ls`. The binary is downloaded
|
||||
from the [nuwiki-rs](https://code.gfran.co/gffranco/nuwiki-rs) releases.
|
||||
|
||||
`plugin/nuwiki.vim` dispatches: Neovim → `require('nuwiki').setup()`; Vim →
|
||||
`nuwiki#lsp#start()` (prefers `vim-lsp`, falls back to `coc.nvim`). On Neovim
|
||||
@@ -323,16 +300,12 @@ provides a no-LSP fallback):
|
||||
|
||||
| Job | Command |
|
||||
|---|---|
|
||||
| fmt | `cargo fmt --all -- --check` |
|
||||
| clippy | `cargo clippy --workspace --all-targets -- -D warnings` |
|
||||
| test | `cargo test --workspace --all-targets` |
|
||||
| keymaps | `development/tests/test-keymaps.sh` (Neovim 0.11) + `test-keymaps-vim.sh` (Vim) |
|
||||
| calendar | `development/tests/test-calendar.sh` (Neovim) + `test-calendar-vim.sh` (Vim) |
|
||||
|
||||
`.gitea/workflows/release.yaml` triggers on `v*` tags: cross-compiles the four
|
||||
Linux targets (gnu/musl × x86_64/aarch64) via `cross`, packages `.tar.gz`, and
|
||||
creates a Gitea release using `RELEASE_TOKEN`. macOS and Windows binaries are
|
||||
built manually and attached to the same release. crates.io publishing is not
|
||||
wired up.
|
||||
`.gitea/workflows/release.yaml` (in [nuwiki-rs](https://code.gfran.co/gffranco/nuwiki-rs))
|
||||
triggers on `v*` tags: cross-compiles the four Linux targets (gnu/musl × x86_64/aarch64)
|
||||
via `cross`, packages `.tar.gz`, and creates a Gitea release using `RELEASE_TOKEN`.
|
||||
|
||||
A change is semver-breaking if it alters `nuwiki-core` AST nodes, the
|
||||
`SyntaxPlugin`/`Renderer` traits, the user-config schema, or removes an LSP
|
||||
|
||||
+40
-11
@@ -10,7 +10,7 @@
|
||||
# Exports:
|
||||
# REPO_ROOT / DEV_DIR / WIKI_DIR — common paths
|
||||
# log — prefixed status line
|
||||
# ensure_binary — build + symlink nuwiki-ls
|
||||
# ensure_binary — verify nuwiki-ls exists (download if missing)
|
||||
# write_sample_wiki DIR — lay down the feature-showcase wiki
|
||||
# reset_dirs DIR... — wipe + recreate owned state dirs
|
||||
# seed_wiki — (re)seed WIKI_DIR fresh each run; never
|
||||
@@ -27,20 +27,34 @@ log() { printf '\033[1;34m[nuwiki-dev]\033[0m %s\n' "$*"; }
|
||||
|
||||
ensure_binary() {
|
||||
local bin="$REPO_ROOT/bin/nuwiki-ls"
|
||||
local built="$REPO_ROOT/target/release/nuwiki-ls"
|
||||
if [[ "${NUWIKI_DEV_SKIP_BUILD:-0}" == "1" ]]; then
|
||||
if [[ ! -x "$bin" ]]; then
|
||||
log "NUWIKI_DEV_SKIP_BUILD=1 but $bin is missing — building anyway"
|
||||
else
|
||||
log "NUWIKI_DEV_SKIP_BUILD=1 — using existing $bin"
|
||||
if [[ -x "$bin" ]]; then
|
||||
log "binary ready: $bin"
|
||||
return
|
||||
fi
|
||||
if [[ "${NUWIKI_DEV_SKIP_BUILD:-0}" == "1" ]]; then
|
||||
log "NUWIKI_DEV_SKIP_BUILD=1 but $bin is missing"
|
||||
return 1
|
||||
fi
|
||||
log "downloading nuwiki-ls…"
|
||||
vim -e -s -c "source $REPO_ROOT/scripts/download_bin.vim" -c "q"
|
||||
if [[ -x "$bin" ]]; then
|
||||
log "binary ready: $bin"
|
||||
return
|
||||
fi
|
||||
log "download failed, falling back to cargo build from nuwiki-rs…"
|
||||
local rs_repo="$REPO_ROOT/nuwiki-rs"
|
||||
if [[ ! -f "$rs_repo/Cargo.toml" ]]; then
|
||||
log "cloning nuwiki-rs …"
|
||||
git clone --depth 1 https://code.gfran.co/gffranco/nuwiki-rs.git "$rs_repo"
|
||||
fi
|
||||
cargo build --release -p nuwiki-ls --manifest-path "$rs_repo/Cargo.toml"
|
||||
if [[ ! -f "$rs_repo/target/release/nuwiki-ls" ]]; then
|
||||
log "cargo build failed"
|
||||
return 1
|
||||
fi
|
||||
log "building nuwiki-ls (release)…"
|
||||
cargo build --release -p nuwiki-ls --manifest-path "$REPO_ROOT/Cargo.toml"
|
||||
mkdir -p "$REPO_ROOT/bin"
|
||||
ln -sf "$built" "$bin"
|
||||
log "binary ready: $bin (built $(date -r "$built" '+%Y-%m-%d %H:%M:%S'))"
|
||||
ln -sf "$rs_repo/target/release/nuwiki-ls" "$bin"
|
||||
log "binary ready: $bin"
|
||||
}
|
||||
|
||||
# write_sample_wiki DIR — lay down a fixture that exercises every vimwiki
|
||||
@@ -78,6 +92,7 @@ One page per feature group:
|
||||
- :NuwikiGenerateLinks
|
||||
- :NuwikiCheckLinks
|
||||
- :NuwikiMakeDiaryNote
|
||||
- :VimwikiColorize red — on a word in [[Syntax]] (or <Leader>wc)
|
||||
|
||||
:sample:smoke-test:sandbox:
|
||||
EOF
|
||||
@@ -92,6 +107,20 @@ Back to [[index]].
|
||||
*bold*, _italic_, *_bold italic_*, ~~strikethrough~~, `inline code`,
|
||||
super^script^ and sub,,script,,, and inline math $e^{i\pi} + 1 = 0$.
|
||||
|
||||
== Colour spans (:VimwikiColorize) ==
|
||||
|
||||
Put the cursor on a word and run `:VimwikiColorize red` (or press
|
||||
`<Leader>wc` and type a colour); or visually select a phrase and press
|
||||
`<Leader>wc`. Each wraps the target in an inline colour-span tag whose
|
||||
markup is concealed, so you see just the word painted in that colour (move
|
||||
the cursor onto it to reveal the raw markup).
|
||||
|
||||
- colorize this important word
|
||||
- now select and colour this whole phrase
|
||||
- hex works too: paint deepskyblue on this token
|
||||
|
||||
Already colorized: <span style="color:#1e90ff">dodger blue</span>.
|
||||
|
||||
== Keywords ==
|
||||
|
||||
TODO STARTED FIXME XXX DONE FIXED STOPPED
|
||||
|
||||
+11
-13
@@ -2,25 +2,17 @@
|
||||
#
|
||||
# start-nvim.sh — launch Neovim with a minimal config that loads nuwiki.
|
||||
#
|
||||
# Builds the language server (release mode), symlinks it into `bin/` so
|
||||
# the plugin's default install path resolves, then spawns Neovim with
|
||||
# `--clean` and a generated `init.lua` that:
|
||||
# * adds this repo to `runtimepath`
|
||||
# * configures wiki_root to a scratch directory under
|
||||
# $XDG_CACHE_HOME/nuwiki-dev/wiki
|
||||
# * calls `require('nuwiki').setup({...})`
|
||||
# The plugin's install() downloads a pre-built release of nuwiki-ls for
|
||||
# your platform from the nuwiki-rs repository. On first launch a missing
|
||||
# binary is auto-downloaded via the bundled Vim script; set
|
||||
# NUWIKI_DEV_SKIP_BUILD=1 to skip the download entirely.
|
||||
#
|
||||
# Usage: ./development/start-nvim.sh [extra args...]
|
||||
# ./development/start-nvim.sh # open the scratch wiki's index
|
||||
# ./development/start-nvim.sh path/to/note.wiki # open a specific file
|
||||
# NUWIKI_DEV_WIKI=/tmp/foo ./development/start-nvim.sh
|
||||
# NUWIKI_DEV_NO_SEED=1 ./development/start-nvim.sh # use WIKI_DIR as-is (no scratch seeding)
|
||||
# NUWIKI_DEV_SKIP_BUILD=1 ./development/start-nvim.sh # reuse the cached binary
|
||||
#
|
||||
# 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 no-op when nothing changed. Set
|
||||
# NUWIKI_DEV_SKIP_BUILD=1 to skip the build step entirely.
|
||||
# NUWIKI_DEV_SKIP_BUILD=1 ./development/start-nvim.sh # skip the download step
|
||||
#
|
||||
# Each launch starts clean: the scratch wiki is reseeded from a pristine
|
||||
# copy and the editor state (cache/data/config) is wiped, so stale edits
|
||||
@@ -64,6 +56,12 @@ vim.g.nuwiki_diary_rel_path = 'diary'
|
||||
|
||||
require('nuwiki').setup({
|
||||
wiki_root = '$WIKI_DIR',
|
||||
-- Exercise the per-wiki display settings via the global shorthand: these
|
||||
-- fold into the single wiki, so :NuwikiTOC writes `== Contents ==` (level 2)
|
||||
-- and HTML export numbers headings.
|
||||
toc_header_level = 2,
|
||||
html_header_numbering = 2,
|
||||
html_header_numbering_sym = ' -',
|
||||
})
|
||||
|
||||
-- Surface log lines (the LSP logs via window/logMessage). Without this
|
||||
|
||||
@@ -22,12 +22,12 @@
|
||||
# ./development/start-vim-coc.sh path/to/note.wiki # open a specific file
|
||||
# NUWIKI_DEV_WIKI=/tmp/foo ./development/start-vim-coc.sh
|
||||
# NUWIKI_DEV_NO_SEED=1 ./development/start-vim-coc.sh # use WIKI_DIR as-is (no scratch seeding)
|
||||
# NUWIKI_DEV_SKIP_BUILD=1 ./development/start-vim-coc.sh # reuse the cached binary
|
||||
# NUWIKI_DEV_SKIP_BUILD=1 ./development/start-vim-coc.sh # skip the download
|
||||
# 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
|
||||
# no-op when nothing changed. Set NUWIKI_DEV_SKIP_BUILD=1 to skip the build.
|
||||
# The LSP binary is downloaded on every launch if missing, falling back to a
|
||||
# cached version. Set NUWIKI_DEV_SKIP_BUILD=1 to skip the download.
|
||||
#
|
||||
# Each launch starts clean: the scratch wiki is reseeded from a pristine
|
||||
# copy and the editor state (viminfo/swap/undo, coc data) is wiped, so stale
|
||||
@@ -72,42 +72,43 @@ ensure_coc() {
|
||||
|
||||
write_coc_settings() {
|
||||
mkdir -p "$DEV_DIR"
|
||||
# Mirror what the vim-lsp client sends (autoload/nuwiki/lsp.vim s:settings()):
|
||||
# both `initializationOptions` (sent on initialize) and `settings.nuwiki`
|
||||
# (served via workspace/configuration). Without these the server falls back
|
||||
# to its own default wiki_root (~/vimwiki) instead of $WIKI_DIR, so it can't
|
||||
# resolve links (e.g. Notes shows as broken) or create-on-follow correctly.
|
||||
# No `languageserver.nuwiki` block here on purpose: this dogfoods nuwiki's
|
||||
# *programmatic* coc registration (autoload/nuwiki/lsp.vim → coc#config). The
|
||||
# plugin registers the server itself from g:nuwiki_* (set in the vimrc),
|
||||
# exactly the "no hand-written coc-settings.json" flow real users get. Only
|
||||
# the jump-command preference (the <CR>-opens-a-tab reproducer) lives here.
|
||||
cat > "$COC_SETTINGS" <<EOF
|
||||
{
|
||||
"coc.preferences.jumpCommand": "$COC_JUMP",
|
||||
"languageserver": {
|
||||
"nuwiki": {
|
||||
"command": "$REPO_ROOT/bin/nuwiki-ls",
|
||||
"filetypes": ["vimwiki"],
|
||||
"initializationOptions": {
|
||||
"wiki_root": "$WIKI_DIR",
|
||||
"file_extension": ".wiki",
|
||||
"syntax": "vimwiki",
|
||||
"log_level": "info",
|
||||
"diagnostic": { "link_severity": "warn" }
|
||||
},
|
||||
"settings": {
|
||||
"nuwiki": {
|
||||
"wiki_root": "$WIKI_DIR",
|
||||
"file_extension": ".wiki",
|
||||
"syntax": "vimwiki",
|
||||
"log_level": "info",
|
||||
"diagnostic": { "link_severity": "warn" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"coc.preferences.jumpCommand": "$COC_JUMP"
|
||||
}
|
||||
EOF
|
||||
}
|
||||
|
||||
write_vimrc() {
|
||||
mkdir -p "$DEV_DIR"
|
||||
|
||||
# Wiki config block. Default: native g:nuwiki_* (single wiki). With
|
||||
# NUWIKI_DEV_VIMWIKI=1: an upstream g:vimwiki_list config instead, to dogfood
|
||||
# the g:vimwiki_* drop-in translation (the path real vimwiki migrants use).
|
||||
local WIKI_CFG
|
||||
if [[ "${NUWIKI_DEV_VIMWIKI:-0}" == "1" ]]; then
|
||||
log "NUWIKI_DEV_VIMWIKI=1 — driving nuwiki from an upstream g:vimwiki_list config"
|
||||
WIKI_CFG="\" Upstream vimwiki config (drop-in). nuwiki translates these.
|
||||
let g:vimwiki_toc_header_level = 2
|
||||
let g:vimwiki_html_header_numbering = 2
|
||||
let g:vimwiki_html_header_numbering_sym = ' -'
|
||||
let g:vimwiki_list = [{'path': '${WIKI_DIR}', 'path_html': '${DEV_DIR}/html', 'ext': '.wiki'}]"
|
||||
else
|
||||
WIKI_CFG="let g:nuwiki_wiki_root = '${WIKI_DIR}'
|
||||
let g:nuwiki_file_extension = '.wiki'
|
||||
let g:nuwiki_diary_rel_path = 'diary'
|
||||
\" Display settings exercise the global shorthand: :NuwikiTOC writes
|
||||
\" \`== Contents ==\` (level 2) and HTML export numbers headings.
|
||||
let g:nuwiki_toc_header_level = 2
|
||||
let g:nuwiki_html_header_numbering = 2
|
||||
let g:nuwiki_html_header_numbering_sym = ' -'"
|
||||
fi
|
||||
|
||||
cat > "$VIMRC" <<EOF
|
||||
" start-vim-coc.sh: generated minimal config for nuwiki development (coc.nvim).
|
||||
" Regenerated on every launch; edit start-vim-coc.sh, not this file.
|
||||
@@ -131,11 +132,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
|
||||
|
||||
@@ -7,18 +7,18 @@
|
||||
# the dev cache on first run so the LSP integration is exercisable
|
||||
# without polluting your real Vim install.
|
||||
#
|
||||
# The plugin's install() downloads a pre-built release of nuwiki-ls for
|
||||
# your platform from the nuwiki-rs repository. On first launch a missing
|
||||
# binary is auto-downloaded via the bundled Vim script; set
|
||||
# NUWIKI_DEV_SKIP_BUILD=1 to skip the download entirely.
|
||||
#
|
||||
# Usage: ./development/start-vim.sh [extra args...]
|
||||
# ./development/start-vim.sh # open the scratch wiki's index
|
||||
# ./development/start-vim.sh path/to/note.wiki # open a specific file
|
||||
# NUWIKI_DEV_WIKI=/tmp/foo ./development/start-vim.sh
|
||||
# NUWIKI_DEV_NO_LSP=1 ./development/start-vim.sh # skip vim-lsp setup
|
||||
# NUWIKI_DEV_NO_SEED=1 ./development/start-vim.sh # use WIKI_DIR as-is (no scratch seeding)
|
||||
# NUWIKI_DEV_SKIP_BUILD=1 ./development/start-vim.sh # reuse the cached binary
|
||||
#
|
||||
# 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 no-op when nothing changed. Set
|
||||
# NUWIKI_DEV_SKIP_BUILD=1 to skip the build step entirely.
|
||||
# NUWIKI_DEV_SKIP_BUILD=1 ./development/start-vim.sh # skip the download step
|
||||
#
|
||||
# Each launch starts clean: the scratch wiki is reseeded from a pristine
|
||||
# copy and the editor state (viminfo/swap/undo) is wiped, so stale edits
|
||||
@@ -87,6 +87,12 @@ let g:nuwiki_wiki_root = '${WIKI_DIR}'
|
||||
let g:nuwiki_file_extension = '.wiki'
|
||||
let g:nuwiki_syntax = 'vimwiki'
|
||||
let g:nuwiki_log_level = 'info'
|
||||
" Exercise the per-wiki display settings via the global shorthand: these fold
|
||||
" into the single wiki, so :NuwikiTOC writes `== Contents ==` (level 2) and
|
||||
" HTML export numbers headings.
|
||||
let g:nuwiki_toc_header_level = 2
|
||||
let g:nuwiki_html_header_numbering = 2
|
||||
let g:nuwiki_html_header_numbering_sym = ' -'
|
||||
|
||||
filetype plugin indent on
|
||||
syntax enable
|
||||
|
||||
@@ -89,6 +89,24 @@ call s:record(winnr('$') == s:wins_before - 1, 'action.window_count_decremented'
|
||||
call s:record(&filetype ==# 'vimwiki', 'action.sets_filetype',
|
||||
\ 'filetype=' . &filetype)
|
||||
|
||||
" ===== calendar follows the current wiki (multi-wiki) =====
|
||||
" Two wikis, each with its own diary entry. track_wiki() (driven by the
|
||||
" BufEnter autocmd in real use) switches which wiki the calendar reads.
|
||||
let s:wA = tempname() | call mkdir(s:wA . '/diary', 'p')
|
||||
let s:wB = tempname() | call mkdir(s:wB . '/diary', 'p')
|
||||
call writefile(['a'], s:wA . '/diary/2026-06-02.wiki')
|
||||
call writefile(['b'], s:wB . '/diary/2026-06-10.wiki')
|
||||
let g:nuwiki_wikis = [{'root': s:wA}, {'root': s:wB}]
|
||||
|
||||
call nuwiki#diary#track_wiki(s:wA . '/index.wiki')
|
||||
call s:record(nuwiki#diary#calendar_sign(2, 6, 2026) ==# '*', 'track.wikiA_own_entry', '')
|
||||
call s:record(nuwiki#diary#calendar_sign(10, 6, 2026) ==# '', 'track.wikiA_not_wikiB_entry', '')
|
||||
|
||||
call nuwiki#diary#track_wiki(s:wB . '/index.wiki')
|
||||
call s:record(nuwiki#diary#calendar_sign(10, 6, 2026) ==# '*', 'track.wikiB_own_entry', '')
|
||||
call s:record(nuwiki#diary#calendar_sign(2, 6, 2026) ==# '', 'track.wikiB_not_wikiA_entry', '')
|
||||
unlet g:nuwiki_wikis
|
||||
|
||||
" ===== Wrap up =====
|
||||
|
||||
call add(s:results, '')
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
# * opting out with use_calendar = false.
|
||||
#
|
||||
# A stub calendar-vim on the runtimepath provides :Calendar and seeds the
|
||||
# default hook names. The LSP binary is built (setup() registers the client);
|
||||
# the calendar paths themselves don't need the server.
|
||||
# default hook names. The LSP binary is downloaded (setup() registers the
|
||||
# client); the calendar paths themselves don't need the server.
|
||||
#
|
||||
# Exit: 0 when both runs are green, 1 otherwise. Skips (0) without nvim.
|
||||
|
||||
@@ -28,13 +28,34 @@ if ! command -v nvim >/dev/null 2>&1; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# setup() registers the LSP client, so build the binary like the other
|
||||
# Neovim harnesses do (incremental, fast).
|
||||
# Download the LSP binary from nuwiki-rs releases (falls back to cargo build).
|
||||
BIN="$REPO_ROOT/bin/nuwiki-ls"
|
||||
log 'building nuwiki-ls (release, incremental)…'
|
||||
cargo build --release -p nuwiki-ls --manifest-path "$REPO_ROOT/Cargo.toml" >/dev/null
|
||||
mkdir -p "$REPO_ROOT/bin"
|
||||
ln -sf "$REPO_ROOT/target/release/nuwiki-ls" "$BIN"
|
||||
log 'downloading nuwiki-ls…'
|
||||
if [[ ! -x "$BIN" ]]; then
|
||||
vim -e -s -c "source $REPO_ROOT/scripts/download_bin.vim" -c "q" >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
# If download failed, fall back to cargo build from nuwiki-rs.
|
||||
if [[ ! -x "$BIN" ]]; then
|
||||
log "download failed, falling back to cargo build from nuwiki-rs…"
|
||||
rs_repo="$REPO_ROOT/nuwiki-rs"
|
||||
if [[ ! -f "$rs_repo/Cargo.toml" ]]; then
|
||||
log "cloning nuwiki-rs …"
|
||||
git clone --depth 1 https://code.gfran.co/gffranco/nuwiki-rs.git "$rs_repo"
|
||||
fi
|
||||
cargo build --release -p nuwiki-ls --manifest-path "$rs_repo/Cargo.toml" >/dev/null
|
||||
if [[ ! -f "$rs_repo/target/release/nuwiki-ls" ]]; then
|
||||
echo 'FAIL: could not build nuwiki-ls binary' >&2
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p "$REPO_ROOT/bin"
|
||||
ln -sf "$rs_repo/target/release/nuwiki-ls" "$BIN"
|
||||
fi
|
||||
|
||||
if [[ ! -x "$BIN" ]]; then
|
||||
echo 'FAIL: could not obtain nuwiki-ls binary' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- Scratch wiki with one seeded diary entry (2026-05-30) ----------------
|
||||
WIKI="$TMP/wiki"
|
||||
|
||||
Executable
+71
@@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# development/tests/test-coc-register-vim.sh — programmatic coc.nvim
|
||||
# registration for the Vim client.
|
||||
#
|
||||
# nuwiki#lsp#start() registers the language server with coc via coc#config so
|
||||
# users don't hand-maintain coc-settings.json. This harness stubs coc#config
|
||||
# (a real autoload/coc.vim on the runtimepath, since Vim's exists('*coc#config')
|
||||
# goes through the autoload mechanism) to record what nuwiki injects, then runs
|
||||
# test-coc-register-vim.vim and asserts the payload.
|
||||
#
|
||||
# Exit code: 0 when every check passes, 1 on any FAIL or missing output.
|
||||
# Skips (exit 0) when vim is not installed.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
|
||||
log() { printf '\033[1;34m[coc-register]\033[0m %s\n' "$*"; }
|
||||
|
||||
if ! command -v vim >/dev/null 2>&1; then
|
||||
log 'vim not installed — skipping'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
TMP="$(mktemp -d -t nuwiki-coc-test-XXXXXX)"
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
# Stub coc#config as a real autoload function so exists('*coc#config') resolves
|
||||
# it. It records the last call into g:_recorded for the test to assert.
|
||||
mkdir -p "$TMP/stub/autoload"
|
||||
cat > "$TMP/stub/autoload/coc.vim" <<'EOF'
|
||||
function! coc#config(section, value) abort
|
||||
let g:_recorded = {'section': a:section, 'value': deepcopy(a:value)}
|
||||
endfunction
|
||||
EOF
|
||||
|
||||
OUT="$TMP/coc.out"
|
||||
VIMRC="$TMP/vimrc"
|
||||
cat > "$VIMRC" <<EOF
|
||||
set nocompatible
|
||||
" Stub dir first so its autoload/coc.vim is the one that resolves.
|
||||
let &runtimepath = '$TMP/stub' . ',' . '$REPO_ROOT' . ',' . &runtimepath
|
||||
" coc.nvim defines :CocConfig at startup; nuwiki uses that as the reliable
|
||||
" 'coc is present' signal. Stub it so the coc branch is taken.
|
||||
command! -nargs=0 CocConfig echo ''
|
||||
EOF
|
||||
|
||||
log 'running Vim coc-registration harness…'
|
||||
NUWIKI_COC_OUT="$OUT" \
|
||||
timeout 30 vim -e -s -u "$VIMRC" \
|
||||
-c "source $REPO_ROOT/development/tests/test-coc-register-vim.vim" \
|
||||
</dev/null >"$TMP/vim.log" 2>&1 || true
|
||||
|
||||
if [[ ! -f "$OUT" ]]; then
|
||||
echo 'coc-registration harness produced no output' >&2
|
||||
echo '--- vim log ---' >&2
|
||||
cat "$TMP/vim.log" >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cat "$OUT"
|
||||
PASSED="$(grep -c '^PASS ' "$OUT" || true)"
|
||||
FAILED="$(grep -c '^FAIL ' "$OUT" || true)"
|
||||
echo "SUMMARY: ${PASSED} passed, ${FAILED} failed"
|
||||
|
||||
if [[ "$FAILED" -ne 0 ]]; then
|
||||
exit 1
|
||||
fi
|
||||
log 'coc registration OK ✓'
|
||||
exit 0
|
||||
@@ -0,0 +1,42 @@
|
||||
" development/tests/test-coc-register-vim.vim — programmatic coc.nvim
|
||||
" registration. Relies on a stub autoload/coc.vim (created by the wrapper) that
|
||||
" records coc#config calls into g:_recorded. Drives nuwiki#lsp#start()'s coc
|
||||
" branch and asserts the exact languageserver config nuwiki injects (command,
|
||||
" filetypes, and the s:settings() payload with the global shorthand folded in).
|
||||
" Writes PASS/FAIL to $NUWIKI_COC_OUT.
|
||||
|
||||
let s:out = []
|
||||
function! s:check(desc, cond) abort
|
||||
call add(s:out, (a:cond ? 'PASS ' : 'FAIL ') . a:desc)
|
||||
endfunction
|
||||
|
||||
" Pretend coc's node service is already up so registration runs synchronously.
|
||||
let g:coc_service_initialized = 1
|
||||
let g:_recorded = {}
|
||||
|
||||
" Make sure the vim-lsp branch is NOT taken.
|
||||
if exists('g:lsp_loaded') | unlet g:lsp_loaded | endif
|
||||
|
||||
" A readable, executable 'binary' so nuwiki#lsp#start() gets past its check.
|
||||
let g:nuwiki_binary_path = executable('true') ? exepath('true') : '/usr/bin/true'
|
||||
|
||||
" Config: single-wiki shorthand + a global display setting that must fold into
|
||||
" the wiki the server sees (the global shorthand).
|
||||
let g:nuwiki_wiki_root = '/tmp/realwiki'
|
||||
let g:nuwiki_toc_header_level = 2
|
||||
|
||||
call nuwiki#lsp#start()
|
||||
|
||||
let s:r = get(g:, '_recorded', {})
|
||||
call s:check('coc#config was called', !empty(s:r))
|
||||
call s:check('section is languageserver.nuwiki', get(s:r, 'section', '') ==# 'languageserver.nuwiki')
|
||||
let s:v = get(s:r, 'value', {})
|
||||
call s:check('command is the binary', get(s:v, 'command', '') ==# g:nuwiki_binary_path)
|
||||
call s:check('filetypes = [vimwiki]', get(s:v, 'filetypes', []) == ['vimwiki'])
|
||||
let s:io = get(s:v, 'initializationOptions', {})
|
||||
call s:check('initOpts has wiki_root', get(s:io, 'wiki_root', '') ==# '/tmp/realwiki')
|
||||
call s:check('global toc_header_level folded into payload', get(s:io, 'toc_header_level', 0) == 2)
|
||||
call s:check('settings.nuwiki mirrors initOpts', get(get(s:v, 'settings', {}), 'nuwiki', {}) ==# s:io)
|
||||
|
||||
call writefile(s:out, $NUWIKI_COC_OUT)
|
||||
qa!
|
||||
@@ -0,0 +1,87 @@
|
||||
-- development/tests/test-global-shorthand.lua — Neovim client global
|
||||
-- shorthand: a per-wiki setting given once at the top level (setup() or
|
||||
-- g:nuwiki_<key>) is folded into every wiki as a default, and a per-wiki
|
||||
-- value overrides it. Mirror of the Vim test-vimwiki-compat checks.
|
||||
--
|
||||
-- Writes PASS/FAIL lines to $NUWIKI_GS_OUT; the wrapper fails on any FAIL.
|
||||
|
||||
local config = require('nuwiki.config')
|
||||
local lsp = require('nuwiki.lsp')
|
||||
|
||||
local out = {}
|
||||
local function check(desc, cond)
|
||||
table.insert(out, (cond and 'PASS ' or 'FAIL ') .. desc)
|
||||
end
|
||||
|
||||
-- ----- setup() top-level scalar folds into every wiki; per-wiki wins -----
|
||||
config.apply({
|
||||
toc_header_level = 2,
|
||||
html_header_numbering = 2,
|
||||
wikis = {
|
||||
{ root = '/tmp/one', name = 'One' },
|
||||
{ root = '/tmp/two', name = 'Two', toc_header_level = 3 },
|
||||
},
|
||||
})
|
||||
local io1 = lsp.init_options()
|
||||
check('w0 inherits global level', io1.wikis[1].toc_header_level == 2)
|
||||
check('w0 inherits numbering', io1.wikis[1].html_header_numbering == 2)
|
||||
check('w1 per-wiki override wins', io1.wikis[2].toc_header_level == 3)
|
||||
check('w1 still inherits numbering', io1.wikis[2].html_header_numbering == 2)
|
||||
|
||||
-- The user's setup table must not be mutated by the fold.
|
||||
check('no mutation of user wikis', config.user.wikis[1].toc_header_level == nil)
|
||||
|
||||
-- ----- built-in defaults are NOT folded (only explicit user values) -----
|
||||
config.apply({
|
||||
wikis = { { root = '/tmp/x', name = 'X' } },
|
||||
})
|
||||
local io2 = lsp.init_options()
|
||||
check('default toc_header not folded', io2.wikis[1].toc_header == nil)
|
||||
check('default level not folded', io2.wikis[1].toc_header_level == nil)
|
||||
|
||||
-- ----- g:nuwiki_<key> acts as a global too -----
|
||||
config.apply({
|
||||
wikis = { { root = '/tmp/y', name = 'Y' } },
|
||||
})
|
||||
vim.g.nuwiki_toc_header_level = 5
|
||||
local io3 = lsp.init_options()
|
||||
check('g:nuwiki_ global folds', io3.wikis[1].toc_header_level == 5)
|
||||
vim.g.nuwiki_toc_header_level = nil
|
||||
|
||||
-- ----- upstream vim.g.vimwiki_list drop-in (the lazy.nvim vimwiki-swap case) --
|
||||
config.apply({}) -- setup() with no native config
|
||||
vim.g.vimwiki_list = {
|
||||
{ path = '~/.vimwiki/personal', path_html = '~/public_html/personal', auto_export = 1 },
|
||||
{ path = '~/.vimwiki/work' },
|
||||
}
|
||||
vim.g.vimwiki_toc_header_level = 2
|
||||
local io4 = lsp.init_options()
|
||||
check('vimwiki_list -> 2 wikis', io4.wikis ~= nil and #io4.wikis == 2)
|
||||
check('path -> root', io4.wikis[1].root == '~/.vimwiki/personal')
|
||||
check('path_html -> html_path', io4.wikis[1].html_path == '~/public_html/personal')
|
||||
check('vimwiki global folds', io4.wikis[1].toc_header_level == 2)
|
||||
check('w2 root', io4.wikis[2].root == '~/.vimwiki/work')
|
||||
-- Buffer-side commands (<Leader>ww) resolve the same wikis.
|
||||
check('wiki_list reads vimwiki_list', config.wiki_list()[1].root == '~/.vimwiki/personal')
|
||||
-- Native g:nuwiki_wikis wins over g:vimwiki_list.
|
||||
vim.g.nuwiki_wikis = { { root = '~/native', name = 'native' } }
|
||||
local io5 = lsp.init_options()
|
||||
check('native g:nuwiki_wikis wins', #io5.wikis == 1 and io5.wikis[1].root == '~/native')
|
||||
vim.g.nuwiki_wikis = nil
|
||||
vim.g.vimwiki_list = nil
|
||||
vim.g.vimwiki_toc_header_level = nil
|
||||
|
||||
-- ----- setup({ wikis }) bridges the Lua config to VimL (g:nuwiki_wikis) -----
|
||||
-- So buffer-side VimL helpers + the vimwiki-sync compat shim see the same
|
||||
-- wikis as the LSP, even though setup() config lives only in Lua.
|
||||
require('nuwiki').setup({ wikis = { { root = '/tmp/a', name = 'A' }, { root = '/tmp/b' } } })
|
||||
check('setup() bridges to g:nuwiki_wikis',
|
||||
vim.g.nuwiki_wikis ~= nil and #vim.g.nuwiki_wikis == 2)
|
||||
check('bridged wiki root visible to VimL',
|
||||
vim.g.nuwiki_wikis and vim.g.nuwiki_wikis[1].root == '/tmp/a')
|
||||
vim.g.nuwiki_wikis = nil
|
||||
|
||||
local f = assert(io.open(os.getenv('NUWIKI_GS_OUT'), 'w'))
|
||||
f:write(table.concat(out, '\n') .. '\n')
|
||||
f:close()
|
||||
vim.cmd('qa!')
|
||||
Executable
+54
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# development/tests/test-global-shorthand.sh — Neovim client global-shorthand
|
||||
# folding (vimwiki-style per-wiki defaults). Runs test-global-shorthand.lua
|
||||
# under headless Neovim; that script asserts the init_options payload folds a
|
||||
# top-level / g:nuwiki_* setting into every wiki, lets a per-wiki value
|
||||
# override it, and does NOT fold built-in defaults.
|
||||
#
|
||||
# Exit code: 0 when every check passes, 1 on any FAIL or missing output.
|
||||
# Skips (exit 0) when nvim is not installed.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
|
||||
log() { printf '\033[1;34m[global-shorthand]\033[0m %s\n' "$*"; }
|
||||
|
||||
if ! command -v nvim >/dev/null 2>&1; then
|
||||
log 'nvim not installed — skipping'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
TMP="$(mktemp -d -t nuwiki-gs-test-XXXXXX)"
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
OUT="$TMP/gs.out"
|
||||
INIT="$TMP/init.lua"
|
||||
cat > "$INIT" <<EOF
|
||||
vim.opt.runtimepath:prepend('$REPO_ROOT')
|
||||
EOF
|
||||
|
||||
log 'running Neovim global-shorthand harness…'
|
||||
NUWIKI_GS_OUT="$OUT" \
|
||||
timeout 30 nvim --clean -u "$INIT" --headless \
|
||||
-c "luafile $REPO_ROOT/development/tests/test-global-shorthand.lua" \
|
||||
>"$TMP/nvim.log" 2>&1 || true
|
||||
|
||||
if [[ ! -f "$OUT" ]]; then
|
||||
echo 'global-shorthand harness produced no output' >&2
|
||||
echo '--- nvim log ---' >&2
|
||||
cat "$TMP/nvim.log" >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cat "$OUT"
|
||||
PASSED="$(grep -c '^PASS ' "$OUT" || true)"
|
||||
FAILED="$(grep -c '^FAIL ' "$OUT" || true)"
|
||||
echo "SUMMARY: ${PASSED} passed, ${FAILED} failed"
|
||||
|
||||
if [[ "$FAILED" -ne 0 ]]; then
|
||||
exit 1
|
||||
fi
|
||||
log 'global shorthand OK ✓'
|
||||
exit 0
|
||||
@@ -0,0 +1,65 @@
|
||||
" development/tests/test-keymaps-vim-prefix.vim — driven by
|
||||
" development/tests/test-keymaps-vim.sh.
|
||||
"
|
||||
" Custom-prefix regression. The shell driver sets
|
||||
" `g:nuwiki_map_prefix = '<Leader>n'` *before* the ftplugin loads, so this
|
||||
" harness asserts the whole wiki command family relocates under `<Leader>n*`
|
||||
" while nothing remains under the default `<Leader>w*`. Mirrors vimwiki's
|
||||
" `g:vimwiki_map_prefix` and the Lua-side `map_prefix` option.
|
||||
|
||||
let s:results = []
|
||||
let s:pass = 0
|
||||
let s:fail = 0
|
||||
let s:out = $NUWIKI_KEYMAP_RESULTS
|
||||
|
||||
function! s:record(ok, name, detail) abort
|
||||
let l:line = (a:ok ? 'PASS' : 'FAIL') . ' ' . a:name
|
||||
if a:detail !=# ''
|
||||
let l:line .= ' — ' . a:detail
|
||||
endif
|
||||
call add(s:results, l:line)
|
||||
if a:ok
|
||||
let s:pass += 1
|
||||
else
|
||||
let s:fail += 1
|
||||
endif
|
||||
endfunction
|
||||
|
||||
function! s:has_map(lhs, mode) abort
|
||||
let l:d = maparg(a:lhs, a:mode, 0, 1)
|
||||
return !empty(l:d) && get(l:d, 'buffer', 0)
|
||||
endfunction
|
||||
|
||||
" Every <prefix>* mapping must now live under <Leader>n (prefix = <Leader>n).
|
||||
let s:relocated = [
|
||||
\ ['<Leader>nw', 'n'], ['<Leader>nt', 'n'], ['<Leader>ns', 'n'], ['<Leader>ni', 'n'],
|
||||
\ ['<Leader>n<Leader>w', 'n'], ['<Leader>n<Leader>y', 'n'], ['<Leader>n<Leader>t', 'n'],
|
||||
\ ['<Leader>n<Leader>m', 'n'], ['<Leader>n<Leader>i', 'n'],
|
||||
\ ['<Leader>nn', 'n'], ['<Leader>nd', 'n'], ['<Leader>nr', 'n'], ['<Leader>nc', 'n'],
|
||||
\ ['<Leader>nh', 'n'], ['<Leader>nhh', 'n'], ['<Leader>nha', 'n'],
|
||||
\ ]
|
||||
for s:e in s:relocated
|
||||
let s:ok = s:has_map(s:e[0], s:e[1])
|
||||
call s:record(s:ok ? 1 : 0, 'prefix.relocated.' . s:e[1] . '.' . s:e[0],
|
||||
\ s:ok ? '' : s:e[0] . ' not bound under custom prefix')
|
||||
endfor
|
||||
|
||||
" The default <Leader>w* family must be GONE (relocated, not duplicated).
|
||||
let s:gone = [
|
||||
\ ['<Leader>ww', 'n'], ['<Leader>wr', 'n'], ['<Leader>wh', 'n'],
|
||||
\ ['<Leader>w<Leader>w', 'n'],
|
||||
\ ]
|
||||
for s:e in s:gone
|
||||
let s:ok = !s:has_map(s:e[0], s:e[1])
|
||||
call s:record(s:ok ? 1 : 0, 'prefix.default_gone.' . s:e[1] . '.' . s:e[0],
|
||||
\ s:ok ? '' : s:e[0] . ' still mapped despite custom prefix')
|
||||
endfor
|
||||
|
||||
" Non-prefix maps (links group) are unaffected by the prefix change.
|
||||
let s:cr = s:has_map('<CR>', 'n')
|
||||
call s:record(s:cr ? 1 : 0, 'prefix.cr_unaffected', s:cr ? '' : '<CR> lost')
|
||||
|
||||
call add(s:results, '')
|
||||
call add(s:results, printf('SUMMARY: %d passed, %d failed', s:pass, s:fail))
|
||||
call writefile(s:results, s:out)
|
||||
execute (s:fail == 0 ? 'qall!' : 'cquit!')
|
||||
@@ -96,7 +96,41 @@ fi
|
||||
|
||||
cat "$RESULTS_OPTOUT"
|
||||
|
||||
if grep -qE '^SUMMARY: [0-9]+ passed, 0 failed' "$RESULTS_OPTOUT"; then
|
||||
if ! grep -qE '^SUMMARY: [0-9]+ passed, 0 failed' "$RESULTS_OPTOUT"; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ===== Custom map_prefix run =====
|
||||
# Fresh Vim instance with g:nuwiki_map_prefix set *before* the ftplugin
|
||||
# loads, so we can assert the whole wiki command family relocates under the
|
||||
# custom prefix and nothing remains under the default <Leader>w*.
|
||||
|
||||
VIMRC_PREFIX="$TMP/vimrc-prefix"
|
||||
cat > "$VIMRC_PREFIX" <<EOF
|
||||
set nocompatible
|
||||
let &runtimepath = '$REPO_ROOT' . ',' . &runtimepath
|
||||
let g:nuwiki_map_prefix = '<Leader>n'
|
||||
filetype plugin indent on
|
||||
syntax enable
|
||||
EOF
|
||||
|
||||
RESULTS_PREFIX="$TMP/results-prefix.txt"
|
||||
|
||||
log "running custom-prefix harness…"
|
||||
NUWIKI_KEYMAP_RESULTS="$RESULTS_PREFIX" \
|
||||
timeout 30 vim -e -s -u "$VIMRC_PREFIX" -c "edit $SEED" -c "source $REPO_ROOT/development/tests/test-keymaps-vim-prefix.vim" \
|
||||
</dev/null >"$TMP/vim-prefix.log" 2>&1 || true
|
||||
|
||||
if [[ ! -f "$RESULTS_PREFIX" ]]; then
|
||||
echo 'keymap custom-prefix harness produced no output' >&2
|
||||
echo '--- vim log ---' >&2
|
||||
cat "$TMP/vim-prefix.log" >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cat "$RESULTS_PREFIX"
|
||||
|
||||
if grep -qE '^SUMMARY: [0-9]+ passed, 0 failed' "$RESULTS_PREFIX"; then
|
||||
exit 0
|
||||
fi
|
||||
exit 1
|
||||
|
||||
@@ -112,7 +112,7 @@ let s:both_forms = [
|
||||
\ 'RebuildTags', '2HTML', '2HTMLBrowse', 'All2HTML', 'Rss',
|
||||
\ 'NormalizeLink', 'RenumberList', 'RenumberAllLists',
|
||||
\ 'ListToggle', 'IncrementListItem', 'DecrementListItem',
|
||||
\ 'CatUrl',
|
||||
\ 'CatUrl', 'ShowVersion', 'Return', 'Var', 'Search',
|
||||
\ ]
|
||||
for s:suffix in s:both_forms
|
||||
for s:prefix in ['Nuwiki', 'Vimwiki']
|
||||
@@ -184,10 +184,13 @@ endfunction
|
||||
|
||||
let s:mapping_surface = [
|
||||
\ ['<CR>', 'n'], ['<S-CR>', 'n'], ['<C-CR>', 'n'], ['<C-S-CR>', 'n'],
|
||||
\ ['<M-CR>', 'n'], ['<D-CR>', 'n'],
|
||||
\ ['<BS>', 'n'], ['<Tab>', 'n'], ['<S-Tab>', 'n'], ['+', 'nx'],
|
||||
\ ['<CR>', 'x'],
|
||||
\ ['<C-Space>', 'nx'], ['<C-@>', 'nx'], ['<Nul>', 'nx'],
|
||||
\ ['gnt', 'n'], ['gln', 'nx'], ['glp', 'nx'], ['glx', 'nx'],
|
||||
\ ['glh', 'n'], ['gll', 'n'], ['gLh', 'n'], ['gLl', 'n'],
|
||||
\ ['gLH', 'n'], ['gLL', 'n'], ['gLR', 'n'],
|
||||
\ ['glr', 'n'], ['gLr', 'n'], ['gl', 'n'], ['gL', 'n'],
|
||||
\ ['gl-', 'n'], ['gL-', 'n'], ['gl*', 'n'], ['gL*', 'n'],
|
||||
\ ['gl#', 'n'], ['gL#', 'n'], ['gl1', 'n'], ['gL1', 'n'],
|
||||
@@ -200,7 +203,8 @@ let s:mapping_surface = [
|
||||
\ ['<A-Left>', 'n'], ['<A-Right>', 'n'],
|
||||
\ ['<Leader>ww', 'n'], ['<Leader>ws', 'n'], ['<Leader>wi', 'n'],
|
||||
\ ['<Leader>w<Leader>w', 'n'], ['<Leader>w<Leader>y', 'n'],
|
||||
\ ['<Leader>w<Leader>t', 'n'], ['<Leader>w<Leader>i', 'n'],
|
||||
\ ['<Leader>w<Leader>t', 'n'], ['<Leader>w<Leader>m', 'n'],
|
||||
\ ['<Leader>w<Leader>i', 'n'],
|
||||
\ ['<Leader>wn', 'n'], ['<Leader>wd', 'n'], ['<Leader>wr', 'n'],
|
||||
\ ['<Leader>wh', 'n'], ['<Leader>whh', 'n'], ['<Leader>wha', 'n'],
|
||||
\ ['<Leader>wc', 'nx'], ['<C-Down>', 'n'], ['<C-Up>', 'n'],
|
||||
@@ -209,7 +213,7 @@ let s:mapping_surface = [
|
||||
\ ['ah', 'ox'], ['ih', 'ox'], ['aH', 'ox'], ['iH', 'ox'],
|
||||
\ ['al', 'ox'], ['il', 'ox'], ['a\', 'ox'], ['i\', 'ox'],
|
||||
\ ['ac', 'ox'], ['ic', 'ox'],
|
||||
\ ['<CR>', 'i'], ['<Tab>', 'i'], ['<S-Tab>', 'i'],
|
||||
\ ['<CR>', 'i'], ['<S-CR>', 'i'], ['<Tab>', 'i'], ['<S-Tab>', 'i'],
|
||||
\ ['<C-D>', 'i'], ['<C-T>', 'i'],
|
||||
\ ['<C-L><C-J>', 'i'], ['<C-L><C-K>', 'i'], ['<C-L><C-M>', 'i'],
|
||||
\ ]
|
||||
@@ -221,6 +225,18 @@ for s:entry in s:mapping_surface
|
||||
endfor
|
||||
endfor
|
||||
|
||||
" ===== Diary <Leader>w<Leader>{t,m} target (collision fix) =====
|
||||
" Upstream: <Leader>w<Leader>t = today in a new tab, <Leader>w<Leader>m =
|
||||
" tomorrow. nuwiki used to bind both to tomorrow.
|
||||
call s:record(
|
||||
\ maparg('<Leader>w<Leader>t', 'n') =~# 'diary_today_tab' ? 1 : 0,
|
||||
\ 'diary.leader_t_opens_today_in_tab',
|
||||
\ 'rhs=' . maparg('<Leader>w<Leader>t', 'n'))
|
||||
call s:record(
|
||||
\ maparg('<Leader>w<Leader>m', 'n') =~# 'diary_tomorrow' ? 1 : 0,
|
||||
\ 'diary.leader_m_opens_tomorrow',
|
||||
\ 'rhs=' . maparg('<Leader>w<Leader>m', 'n'))
|
||||
|
||||
" ===== Header nav (pure VimL) =====
|
||||
|
||||
call s:run('headers.]]_jumps_to_next', {
|
||||
@@ -320,6 +336,28 @@ call s:run('cr.adds_new_table_row', {
|
||||
\ 'keys': "A\<CR>x\<Esc>",
|
||||
\ 'expect_lines': ['| a | b |', '|x | |'],
|
||||
\ })
|
||||
" Regression: a `|` inside `[[u|t]]` must not split the cell — the link stays
|
||||
" intact and the row keeps 2 columns (fresh row has 2 cells, not 3).
|
||||
call s:run('cr.table_link_cell_pipe_not_a_separator', {
|
||||
\ 'lines': ['| a | [[u|t]] |'],
|
||||
\ 'cursor': [1, 4],
|
||||
\ 'keys': "A\<CR>x\<Esc>",
|
||||
\ 'expect_lines': ['| a | [[u|t]] |', '|x | |'],
|
||||
\ })
|
||||
" Insert <S-CR>: multiline list item — continue with NO marker, aligned under
|
||||
" the text ("- " → 2 cols; "- [ ] " → 6 cols).
|
||||
call s:run('cr.shift_cr_multiline', {
|
||||
\ 'lines': ['- item'],
|
||||
\ 'cursor': [1, 6],
|
||||
\ 'keys': "A\<S-CR>x\<Esc>",
|
||||
\ 'expect_lines': ['- item', ' x'],
|
||||
\ })
|
||||
call s:run('cr.shift_cr_multiline_checkbox', {
|
||||
\ 'lines': ['- [ ] item'],
|
||||
\ 'cursor': [1, 10],
|
||||
\ 'keys': "A\<S-CR>x\<Esc>",
|
||||
\ 'expect_lines': ['- [ ] item', ' x'],
|
||||
\ })
|
||||
|
||||
" ===== Insert-mode smart_tab / smart_shift_tab (Cluster 6) =====
|
||||
|
||||
@@ -477,6 +515,112 @@ call s:record(
|
||||
\ 'ext=' . get(get(s:wl, 1, {}), 'ext', ''))
|
||||
unlet g:nuwiki_wikis
|
||||
|
||||
" ===== Colorize (cword, visual selection, ranged command) =====
|
||||
" The command is -nargs=1 -range: normal invocation wraps the cword; a visual
|
||||
" range (`:'<,'>VimwikiColorize` / the x-mode <Leader>wc map) wraps the
|
||||
" selection. Run these while still on the vimwiki buffer (the buffer-local
|
||||
" command must be defined).
|
||||
call s:set_buf(['word here'])
|
||||
call cursor(1, 1)
|
||||
silent VimwikiColorize red
|
||||
call s:record(
|
||||
\ getline(1) ==# '<span style="color:red">word</span> here' ? 1 : 0,
|
||||
\ 'colorize.command_wraps_cword',
|
||||
\ 'got ' . string(getline(1)))
|
||||
|
||||
" Visual selection → wraps the selection (visual flag passed explicitly).
|
||||
call s:set_buf(['one two three'])
|
||||
call cursor(1, 5)
|
||||
call feedkeys("viw\<Esc>", 'tx')
|
||||
call nuwiki#commands#colorize('blue', 2)
|
||||
call s:record(
|
||||
\ getline(1) ==# 'one <span style="color:blue">two</span> three' ? 1 : 0,
|
||||
\ 'colorize.visual_wraps_selection',
|
||||
\ 'got ' . string(getline(1)))
|
||||
|
||||
" Ranged command must not raise E481 and wraps the selection.
|
||||
call s:set_buf(['alpha beta gamma'])
|
||||
call cursor(1, 8)
|
||||
call feedkeys("viw\<Esc>", 'tx')
|
||||
let s:cz_err = ''
|
||||
try
|
||||
silent '<,'>VimwikiColorize green
|
||||
catch
|
||||
let s:cz_err = v:exception
|
||||
endtry
|
||||
call s:record(
|
||||
\ s:cz_err ==# '' && getline(1) ==# 'alpha <span style="color:green">beta</span> gamma' ? 1 : 0,
|
||||
\ 'colorize.ranged_command_no_error',
|
||||
\ 'err=' . s:cz_err . ' got ' . string(getline(1)))
|
||||
|
||||
" Colour spans are concealed down to their text. Put the span on a non-cursor
|
||||
" line so concealcursor (=c) doesn't reveal it, then refresh + inspect.
|
||||
call s:set_buf(['plain line', '<span style="color:red">word</span> tail'])
|
||||
call cursor(1, 1)
|
||||
call nuwiki#colors#refresh()
|
||||
let s:cz_l = getline(2)
|
||||
let s:cz_open = synconcealed(2, 1)[0]
|
||||
let s:cz_word = synconcealed(2, stridx(s:cz_l, 'word') + 1)
|
||||
let s:cz_close = synconcealed(2, stridx(s:cz_l, '</span>') + 1)[0]
|
||||
call s:record(
|
||||
\ s:cz_open == 1 && s:cz_close == 1 && s:cz_word[0] == 0
|
||||
\ && synIDattr(synID(2, stridx(s:cz_l, 'word') + 1, 1), 'name') ==# 'nuwikiColorSpan_red' ? 1 : 0,
|
||||
\ 'colorize.conceal_hides_tags_shows_text',
|
||||
\ 'open=' . s:cz_open . ' close=' . s:cz_close . ' word=' . s:cz_word[0]
|
||||
\ . ' grp=' . synIDattr(synID(2, stridx(s:cz_l, 'word') + 1, 1), 'name'))
|
||||
|
||||
" Running the command conceals the new span immediately (colorize refreshes;
|
||||
" no manual step). Move the cursor off the line so concealcursor reveals nothing.
|
||||
call s:set_buf(['target word', 'other line'])
|
||||
call cursor(1, 1)
|
||||
silent VimwikiColorize teal
|
||||
call cursor(2, 1)
|
||||
let s:cz_l2 = getline(1)
|
||||
call s:record(
|
||||
\ synconcealed(1, 1)[0] == 1
|
||||
\ && synIDattr(synID(1, stridx(s:cz_l2, 'target') + 1, 1), 'name') ==# 'nuwikiColorSpan_teal' ? 1 : 0,
|
||||
\ 'colorize.command_conceals_immediately',
|
||||
\ 'concealed=' . synconcealed(1, 1)[0]
|
||||
\ . ' grp=' . synIDattr(synID(1, stridx(s:cz_l2, 'target') + 1, 1), 'name'))
|
||||
|
||||
" A span whose colour Vim can't allocate (rgb()/prose) must be skipped — not
|
||||
" abort refresh, not conceal — while a real span afterwards still conceals.
|
||||
call s:set_buf(['plain', 'x <span style="color:rgb(1,2,3)">y</span> z', '<span style="color:red">word</span> z'])
|
||||
call cursor(1, 1)
|
||||
let s:cz_e = ''
|
||||
try
|
||||
call nuwiki#colors#refresh()
|
||||
catch
|
||||
let s:cz_e = v:exception
|
||||
endtry
|
||||
call s:record(
|
||||
\ s:cz_e ==# ''
|
||||
\ && synconcealed(3, 1)[0] == 1
|
||||
\ && synconcealed(2, stridx(getline(2), '<span') + 1)[0] == 0 ? 1 : 0,
|
||||
\ 'colorize.skips_unallocatable_colour',
|
||||
\ 'err=' . s:cz_e . ' real=' . synconcealed(3, 1)[0]
|
||||
\ . ' rgb=' . synconcealed(2, stridx(getline(2), '<span') + 1)[0])
|
||||
|
||||
" ===== NormalizeLink visual wraps the selection (pure-VimL) =====
|
||||
" `:VimwikiNormalizeLink 1` / the x-mode `+` wrap the `'<`/`'>` selection.
|
||||
call s:set_buf(['one two three'])
|
||||
call cursor(1, 5)
|
||||
call feedkeys("viw\<Esc>", 'tx')
|
||||
call nuwiki#commands#normalize_link(1)
|
||||
call s:record(
|
||||
\ getline(1) ==# 'one [[two]] three' ? 1 : 0,
|
||||
\ 'normalize.visual_wraps_selection',
|
||||
\ 'got ' . string(getline(1)))
|
||||
|
||||
" ===== Visual <CR> mapping wraps the selection (== visual `+`) =====
|
||||
call s:set_buf(['alpha beta gamma'])
|
||||
call cursor(1, 7)
|
||||
call feedkeys("viw\<CR>", 'tx')
|
||||
call s:record(
|
||||
\ getline(1) ==# 'alpha [[beta]] gamma' ? 1 : 0,
|
||||
\ 'map.visual_cr_wraps_selection',
|
||||
\ 'got ' . string(getline(1)))
|
||||
|
||||
" ===== Follow-link resolves + opens in the current window (regression) =====
|
||||
" follow_link_or_create() must resolve the definition itself and `:edit` the
|
||||
" target in the CURRENT window — including a page that does not exist yet
|
||||
@@ -521,6 +665,263 @@ unlet g:_nuwiki_target
|
||||
" follow_link_drop() request mechanism; its end-to-end coverage is the manual
|
||||
" development/start-vim.sh check.
|
||||
|
||||
" ===== :VimwikiTable -nargs=* (cols+rows) =====
|
||||
" Was -nargs=1, which raised E488 on `:VimwikiTable 4 3`. The table insert
|
||||
" itself is an LSP roundtrip (no server in this headless harness), so we only
|
||||
" assert the arg parse succeeds — i.e. no E488 trailing-characters error.
|
||||
call s:set_buf([''])
|
||||
call cursor(1, 1)
|
||||
let s:tbl_err = ''
|
||||
try
|
||||
silent VimwikiTable 4 3
|
||||
catch /E488/
|
||||
let s:tbl_err = v:exception
|
||||
catch
|
||||
" A non-E488 error (e.g. no LSP attached) is expected and fine here.
|
||||
endtry
|
||||
call s:record(
|
||||
\ s:tbl_err ==# '' ? 1 : 0,
|
||||
\ 'cmd.VimwikiTable_accepts_cols_rows',
|
||||
\ 'err=' . s:tbl_err)
|
||||
|
||||
" ===== :VimwikiListChangeLvl -range -nargs=+ =====
|
||||
" Was -nargs=? (range-less): `:1,3VimwikiListChangeLvl increase 0` raised E481
|
||||
" (no range allowed) and/or E488 (trailing chars). The level change is an LSP
|
||||
" roundtrip (no server here), so assert only the attribute parse: no E481/E488.
|
||||
call s:set_buf(['- a', '- b', '- c'])
|
||||
let s:lcl_err = ''
|
||||
try
|
||||
silent 1,3VimwikiListChangeLvl increase 0
|
||||
catch /E481\|E488/
|
||||
let s:lcl_err = v:exception
|
||||
catch
|
||||
" non-attribute error (e.g. no LSP) is expected and fine here.
|
||||
endtry
|
||||
call s:record(
|
||||
\ s:lcl_err ==# '' ? 1 : 0,
|
||||
\ 'cmd.VimwikiListChangeLvl_accepts_range_and_args',
|
||||
\ 'err=' . s:lcl_err)
|
||||
|
||||
" ===== :VimwikiSearch / VWS -nargs=* + :VimwikiTableAlignQ/W -nargs=? =====
|
||||
" Upstream attributes: Search/VWS take -nargs=* (was -nargs=1, so a multi-word
|
||||
" `:VimwikiSearch foo bar` raised E488); TableAlignQ/W take -nargs=? (were bare,
|
||||
" so `:VimwikiTableAlignQ 2` raised E488). lvimgrep/align run for real here, so
|
||||
" swallow any non-attribute error and assert only that E488 is gone.
|
||||
let s:attr_err = ''
|
||||
try
|
||||
silent! VimwikiSearch foo bar
|
||||
silent! VWS foo bar
|
||||
silent! VimwikiTableAlignQ 2
|
||||
silent! VimwikiTableAlignW 2
|
||||
silent! NuwikiTableAlign 2
|
||||
catch /E488/
|
||||
let s:attr_err = v:exception
|
||||
catch
|
||||
" runtime errors (no match, empty table, …) are expected and fine here.
|
||||
endtry
|
||||
call s:record(
|
||||
\ s:attr_err ==# '' ? 1 : 0,
|
||||
\ 'cmd.search_and_tablealign_nargs',
|
||||
\ 'err=' . s:attr_err)
|
||||
|
||||
" A no-match search must be caught (friendly message), not raise a raw E480.
|
||||
let s:nm_err = ''
|
||||
try
|
||||
VimwikiSearch zzznomatchxyzqqq
|
||||
catch /E480/
|
||||
let s:nm_err = v:exception
|
||||
catch
|
||||
" other errors (no wiki files, etc.) are fine — only E480 is the regression.
|
||||
endtry
|
||||
call s:record(s:nm_err ==# '' ? 1 : 0,
|
||||
\ 'cmd.VimwikiSearch_no_match_graceful', 'err=' . s:nm_err)
|
||||
|
||||
" ===== auto_header inserts `= Stem =` on a new wiki page; skips the index =====
|
||||
let s:ah_root = fnamemodify(expand(nuwiki#commands#wiki_list()[0].root), ':p')
|
||||
if s:ah_root[len(s:ah_root)-1:] !=# '/' | let s:ah_root .= '/' | endif
|
||||
new
|
||||
execute 'file ' . fnameescape('/tmp/nuwiki-ah-plain/MyNewPage.wiki')
|
||||
call nuwiki#commands#auto_header()
|
||||
call s:record(getline(1) ==# '= MyNewPage =' ? 1 : 0,
|
||||
\ 'cmd.auto_header_inserts', 'line1=' . getline(1))
|
||||
bwipeout!
|
||||
new
|
||||
execute 'file ' . fnameescape(s:ah_root . 'index.wiki')
|
||||
call nuwiki#commands#auto_header()
|
||||
call s:record(getline(1) ==# '' ? 1 : 0,
|
||||
\ 'cmd.auto_header_skips_index', 'line1=' . getline(1))
|
||||
bwipeout!
|
||||
unlet s:ah_root
|
||||
|
||||
" ===== :VimwikiGoto -nargs=* =====
|
||||
" Was -nargs=1, so a page name with a space raised E488 (and the empty-arg
|
||||
" prompt fallback was unreachable). Now -nargs=*: a multi-word arg must parse.
|
||||
" The goto is an LSP roundtrip (no server here); swallow non-attribute errors.
|
||||
let s:goto_err = ''
|
||||
try
|
||||
silent! VimwikiGoto My Page
|
||||
catch /E471\|E488/
|
||||
let s:goto_err = v:exception
|
||||
catch
|
||||
" no-LSP / not-found errors are expected and fine here.
|
||||
endtry
|
||||
call s:record(
|
||||
\ s:goto_err ==# '' ? 1 : 0,
|
||||
\ 'cmd.VimwikiGoto_accepts_multiword',
|
||||
\ 'err=' . s:goto_err)
|
||||
|
||||
" ===== :VimwikiVar get/set =====
|
||||
" `:VimwikiVar key val` sets g:nuwiki_<key>; bare/get forms just echo.
|
||||
silent VimwikiVar testkey hello world
|
||||
call s:record(get(g:, 'nuwiki_testkey', '') ==# 'hello world' ? 1 : 0,
|
||||
\ 'cmd.VimwikiVar_sets_global',
|
||||
\ 'g:nuwiki_testkey=' . get(g:, 'nuwiki_testkey', '(unset)'))
|
||||
|
||||
" ===== global entry points work outside a wiki buffer =====
|
||||
" Index / TabIndex / Var / ShowVersion are defined globally (upstream defines
|
||||
" the Index family in plugin/). Open a throwaway non-wiki buffer and confirm.
|
||||
new
|
||||
setlocal buftype=nofile
|
||||
let s:glob_ok = exists(':VimwikiIndex') == 2 && exists(':VimwikiTabIndex') == 2
|
||||
\ && exists(':VimwikiVar') == 2 && exists(':VimwikiShowVersion') == 2
|
||||
call s:record(s:glob_ok ? 1 : 0, 'cmd.global_entry_points',
|
||||
\ 'Index/TabIndex/Var/ShowVersion visible outside a wiki buffer')
|
||||
bwipeout!
|
||||
|
||||
" ===== autowriteall / table_auto_fmt (Vim path) =====
|
||||
" autowriteall (default on) sets Vim's global &autowriteall on the wiki buffer.
|
||||
call s:record(&autowriteall ? 1 : 0, 'config.autowriteall_applied',
|
||||
\ '&autowriteall=' . &autowriteall)
|
||||
" table_auto_fmt (default on) registers the NuwikiTableAutoFmt InsertLeave group.
|
||||
call s:record(exists('#NuwikiTableAutoFmt#InsertLeave') ? 1 : 0,
|
||||
\ 'config.table_auto_fmt_autocmd',
|
||||
\ exists('#NuwikiTableAutoFmt#InsertLeave') ? '' : 'no InsertLeave autocmd')
|
||||
|
||||
" ===== :VimwikiRemoveDone -bang -range =====
|
||||
" Upstream is `-range`; nuwiki had `-bang` only, so `:'<,'>VimwikiRemoveDone`
|
||||
" raised E481. Now `-bang -range`: a ranged invocation must parse (no E481),
|
||||
" and the `!` whole-buffer form must still work. The removal is an LSP
|
||||
" roundtrip (no server here), so assert only the attribute parse.
|
||||
call s:set_buf(['- [X] a', '- [ ] b', '- [X] c'])
|
||||
let s:rd_err = ''
|
||||
try
|
||||
silent 1,2VimwikiRemoveDone
|
||||
silent VimwikiRemoveDone!
|
||||
catch /E481/
|
||||
let s:rd_err = v:exception
|
||||
catch
|
||||
" non-E481 (e.g. no LSP attached) is expected and fine here.
|
||||
endtry
|
||||
call s:record(
|
||||
\ s:rd_err ==# '' ? 1 : 0,
|
||||
\ 'cmd.VimwikiRemoveDone_accepts_range_and_bang',
|
||||
\ 'err=' . s:rd_err)
|
||||
|
||||
" ===== :VimwikiToggleRejectedListItem -range =====
|
||||
" Was bare (range-less): `:1,3VimwikiToggleRejectedListItem` raised E481. The
|
||||
" reject is an LSP roundtrip (no server here), so assert only the attribute
|
||||
" parse: no E481.
|
||||
call s:set_buf(['- [ ] a', '- [ ] b', '- [ ] c'])
|
||||
let s:trl_err = ''
|
||||
try
|
||||
silent 1,3VimwikiToggleRejectedListItem
|
||||
catch /E481/
|
||||
let s:trl_err = v:exception
|
||||
catch
|
||||
" non-E481 (e.g. no LSP attached) is expected and fine here.
|
||||
endtry
|
||||
call s:record(
|
||||
\ s:trl_err ==# '' ? 1 : 0,
|
||||
\ 'cmd.VimwikiToggleRejectedListItem_accepts_range',
|
||||
\ 'err=' . s:trl_err)
|
||||
|
||||
" ===== :VimwikiSplitLink -nargs=* =====
|
||||
" Upstream takes optional reuse/move-cursor flags; nuwiki's defs took none, so
|
||||
" `:VimwikiSplitLink 0 1` raised E488. Now -nargs=*: assert the arg parse
|
||||
" succeeds. The split opens a window (the follow itself is an LSP no-op here);
|
||||
" collapse back to one window afterward.
|
||||
call s:set_buf(['[[Target]]'])
|
||||
let s:sl_err = ''
|
||||
try
|
||||
silent VimwikiSplitLink 0 1
|
||||
catch /E488/
|
||||
let s:sl_err = v:exception
|
||||
catch
|
||||
" non-E488 (e.g. no LSP attached) is expected and fine here.
|
||||
endtry
|
||||
silent! only
|
||||
call s:record(
|
||||
\ s:sl_err ==# '' ? 1 : 0,
|
||||
\ 'cmd.VimwikiSplitLink_accepts_args',
|
||||
\ 'err=' . s:sl_err)
|
||||
|
||||
" ===== Command-line completion (nuwiki#complete#*) =====
|
||||
" Pure client-side completers backing the -complete= specs on VimwikiGoto
|
||||
" (pages), VimwikiColorize (colours), and the tag commands. Driven directly
|
||||
" (no LSP) against a throwaway wiki dir.
|
||||
let s:cw = tempname()
|
||||
call mkdir(s:cw, 'p')
|
||||
call writefile(['= alpha =', 'tagged :foo:bar:'], s:cw . '/alpha.wiki')
|
||||
call writefile(['= beta ='], s:cw . '/beta.wiki')
|
||||
let s:save_root = get(g:, 'nuwiki_wiki_root', v:null)
|
||||
let g:nuwiki_wiki_root = s:cw
|
||||
|
||||
let s:pg = nuwiki#complete#pages('a', '', 0)
|
||||
call s:record(
|
||||
\ (index(s:pg, 'alpha') >= 0 && index(s:pg, 'beta') < 0) ? 1 : 0,
|
||||
\ 'complete.pages_globs_and_filters',
|
||||
\ 'got ' . string(s:pg))
|
||||
|
||||
let s:tg = nuwiki#complete#tags('', '', 0)
|
||||
call s:record(
|
||||
\ (index(s:tg, 'foo') >= 0 && index(s:tg, 'bar') >= 0) ? 1 : 0,
|
||||
\ 'complete.tags_scans_wiki_files',
|
||||
\ 'got ' . string(s:tg))
|
||||
|
||||
if s:save_root is v:null
|
||||
unlet g:nuwiki_wiki_root
|
||||
else
|
||||
let g:nuwiki_wiki_root = s:save_root
|
||||
endif
|
||||
call delete(s:cw, 'rf')
|
||||
|
||||
" Colours come from `color:NAME` spans already in the buffer.
|
||||
call s:set_buf(['<span style="color:tomato">x</span> plain'])
|
||||
let s:cl = nuwiki#complete#colors('t', '', 0)
|
||||
call s:record(
|
||||
\ index(s:cl, 'tomato') >= 0 ? 1 : 0,
|
||||
\ 'complete.colors_from_buffer_spans',
|
||||
\ 'got ' . string(s:cl))
|
||||
|
||||
" ===== Diary-note family -count=0 (wiki selector) =====
|
||||
" Was bare: `:2VimwikiMakeDiaryNote` raised E481. The diary open is an LSP
|
||||
" roundtrip (no server here), so assert only the attribute parse: no E481.
|
||||
call s:set_buf(['= page ='])
|
||||
let s:dc_err = ''
|
||||
try
|
||||
silent 2VimwikiMakeDiaryNote
|
||||
catch /E481/
|
||||
let s:dc_err = v:exception
|
||||
catch
|
||||
" non-E481 (e.g. no LSP attached) is expected and fine here.
|
||||
endtry
|
||||
call s:record(
|
||||
\ s:dc_err ==# '' ? 1 : 0,
|
||||
\ 'cmd.VimwikiMakeDiaryNote_accepts_count',
|
||||
\ 'err=' . s:dc_err)
|
||||
|
||||
let s:di_err = ''
|
||||
try
|
||||
silent 2VimwikiDiaryIndex
|
||||
catch /E481/
|
||||
let s:di_err = v:exception
|
||||
catch
|
||||
endtry
|
||||
call s:record(
|
||||
\ s:di_err ==# '' ? 1 : 0,
|
||||
\ 'cmd.VimwikiDiaryIndex_accepts_count',
|
||||
\ 'err=' . s:di_err)
|
||||
|
||||
" ===== Wrap up =====
|
||||
|
||||
call add(s:results, '')
|
||||
|
||||
@@ -139,7 +139,7 @@ vim.defer_fn(function()
|
||||
'RebuildTags', '2HTML', '2HTMLBrowse', 'All2HTML', 'Rss',
|
||||
'NormalizeLink', 'RenumberList', 'RenumberAllLists',
|
||||
'ListToggle', 'IncrementListItem', 'DecrementListItem',
|
||||
'CatUrl',
|
||||
'CatUrl', 'ShowVersion', 'Return', 'Var', 'Search',
|
||||
}
|
||||
for _, suffix in ipairs(both_forms) do
|
||||
for _, prefix in ipairs({ 'Nuwiki', 'Vimwiki' }) do
|
||||
@@ -206,11 +206,15 @@ vim.defer_fn(function()
|
||||
local mapping_surface = {
|
||||
-- Links
|
||||
{ '<CR>', 'n' }, { '<S-CR>', 'n' }, { '<C-CR>', 'n' }, { '<C-S-CR>', 'n' },
|
||||
{ '<M-CR>', 'n' }, { '<D-CR>', 'n' },
|
||||
{ '<BS>', 'n' }, { '<Tab>', 'n' }, { '<S-Tab>', 'n' }, { '+', 'nx' },
|
||||
{ '<CR>', 'x' }, -- visual normalize-link (== visual `+`)
|
||||
-- Lists
|
||||
{ '<C-Space>', 'nx' }, { '<C-@>', 'nx' }, { '<Nul>', 'nx' },
|
||||
{ 'gnt', 'n' }, { 'gln', 'nx' }, { 'glp', 'nx' }, { 'glx', 'nx' },
|
||||
{ 'glh', 'n' }, { 'gll', 'n' }, { 'gLh', 'n' }, { 'gLl', 'n' },
|
||||
-- Upstream case-variant aliases of gLh/gLl/gLr.
|
||||
{ 'gLH', 'n' }, { 'gLL', 'n' }, { 'gLR', 'n' },
|
||||
{ 'glr', 'n' }, { 'gLr', 'n' }, { 'gl', 'n' }, { 'gL', 'n' },
|
||||
{ 'gl-', 'n' }, { 'gL-', 'n' }, { 'gl*', 'n' }, { 'gL*', 'n' },
|
||||
{ 'gl#', 'n' }, { 'gL#', 'n' }, { 'gl1', 'n' }, { 'gL1', 'n' },
|
||||
@@ -226,7 +230,8 @@ vim.defer_fn(function()
|
||||
-- Wiki / diary / export
|
||||
{ '<Leader>ww', 'n' }, { '<Leader>ws', 'n' }, { '<Leader>wi', 'n' },
|
||||
{ '<Leader>w<Leader>w', 'n' }, { '<Leader>w<Leader>y', 'n' },
|
||||
{ '<Leader>w<Leader>t', 'n' }, { '<Leader>w<Leader>i', 'n' },
|
||||
{ '<Leader>w<Leader>t', 'n' }, { '<Leader>w<Leader>m', 'n' },
|
||||
{ '<Leader>w<Leader>i', 'n' },
|
||||
{ '<Leader>wn', 'n' }, { '<Leader>wd', 'n' }, { '<Leader>wr', 'n' },
|
||||
{ '<Leader>wh', 'n' }, { '<Leader>whh', 'n' }, { '<Leader>wha', 'n' },
|
||||
{ '<Leader>wc', 'nx' }, { '<C-Down>', 'n' }, { '<C-Up>', 'n' },
|
||||
@@ -238,7 +243,7 @@ vim.defer_fn(function()
|
||||
{ 'al', 'ox' }, { 'il', 'ox' }, { 'a\\', 'ox' }, { 'i\\', 'ox' },
|
||||
{ 'ac', 'ox' }, { 'ic', 'ox' },
|
||||
-- Insert-mode
|
||||
{ '<CR>', 'i' }, { '<Tab>', 'i' }, { '<S-Tab>', 'i' },
|
||||
{ '<CR>', 'i' }, { '<S-CR>', 'i' }, { '<Tab>', 'i' }, { '<S-Tab>', 'i' },
|
||||
{ '<C-D>', 'i' }, { '<C-T>', 'i' },
|
||||
{ '<C-L><C-J>', 'i' }, { '<C-L><C-K>', 'i' }, { '<C-L><C-M>', 'i' },
|
||||
}
|
||||
@@ -251,6 +256,80 @@ vim.defer_fn(function()
|
||||
end
|
||||
end
|
||||
|
||||
-- ===== Config-driven buffer behaviors =====
|
||||
-- autowriteall (default on) mirrors into Vim's global 'autowriteall' while
|
||||
-- the wiki buffer is current.
|
||||
record(vim.o.autowriteall == true, 'config.autowriteall_applied',
|
||||
vim.o.autowriteall and '' or 'autowriteall not set on the wiki buffer')
|
||||
-- table_auto_fmt wiring: with the option enabled, attaching a buffer
|
||||
-- registers a buffer-local InsertLeave autocmd. (It's off in the harness
|
||||
-- setup so the async re-align doesn't perturb the table-nav cases below, so
|
||||
-- verify on a throwaway scratch buffer in its own window.)
|
||||
do
|
||||
local cfg = require('nuwiki.config')
|
||||
cfg.options.table_auto_fmt = true
|
||||
vim.cmd('new')
|
||||
local sbuf = vim.api.nvim_get_current_buf()
|
||||
require('nuwiki.ftplugin').attach(sbuf)
|
||||
local found = false
|
||||
for _, a in ipairs(vim.api.nvim_get_autocmds({ event = 'InsertLeave', buffer = sbuf })) do
|
||||
if a.group_name and a.group_name:match('^nuwiki_taf_') then
|
||||
found = true
|
||||
break
|
||||
end
|
||||
end
|
||||
record(found, 'config.table_auto_fmt_autocmd',
|
||||
found and '' or 'attach did not register the InsertLeave autocmd when enabled')
|
||||
cfg.options.table_auto_fmt = false
|
||||
vim.cmd('bwipeout!')
|
||||
end
|
||||
|
||||
-- :VimwikiVar key val sets the in-memory option.
|
||||
do
|
||||
vim.cmd('VimwikiVar testkey hello')
|
||||
local v = require('nuwiki.config').options.testkey
|
||||
record(v == 'hello', 'cmd.VimwikiVar_sets_option', 'options.testkey=' .. tostring(v))
|
||||
end
|
||||
|
||||
-- Index / TabIndex / Var / ShowVersion are global (work outside a wiki
|
||||
-- buffer). Open a throwaway scratch buffer and confirm they're visible.
|
||||
do
|
||||
vim.cmd('enew')
|
||||
vim.bo.buftype = 'nofile'
|
||||
local ok = vim.fn.exists(':VimwikiIndex') == 2
|
||||
and vim.fn.exists(':VimwikiTabIndex') == 2
|
||||
and vim.fn.exists(':VimwikiVar') == 2
|
||||
and vim.fn.exists(':VimwikiShowVersion') == 2
|
||||
record(ok, 'cmd.global_entry_points',
|
||||
ok and '' or 'a global command is not visible outside a wiki buffer')
|
||||
vim.cmd('bwipeout!')
|
||||
end
|
||||
|
||||
-- auto_header: the function inserts `= Stem =` on an empty new wiki buffer,
|
||||
-- and skips the wiki index page. Operate on dedicated buffer handles so the
|
||||
-- harness's own buffer is untouched.
|
||||
do
|
||||
local wroot = require('nuwiki.config').wiki_cfg(0).root
|
||||
local root = vim.fn.fnamemodify(vim.fn.expand(wroot), ':p')
|
||||
if root:sub(-1) ~= '/' then root = root .. '/' end
|
||||
local function ah_line(name)
|
||||
local buf = vim.api.nvim_create_buf(true, false)
|
||||
vim.api.nvim_buf_set_name(buf, name)
|
||||
require('nuwiki.commands').auto_header(buf)
|
||||
local line = vim.api.nvim_buf_get_lines(buf, 0, 1, false)[1] or ''
|
||||
vim.api.nvim_buf_delete(buf, { force = true })
|
||||
return line
|
||||
end
|
||||
-- A normal page (outside any wiki root) → header inserted.
|
||||
local got = ah_line('/tmp/nuwiki-ah-plain/MyNewPage.wiki')
|
||||
record(got == '= MyNewPage =', 'cmd.auto_header_inserts', 'line1=' .. got)
|
||||
-- The diary index page → skipped (no header). (The wiki index itself is the
|
||||
-- harness's open buffer, so use the diary index to avoid an E95 collision.)
|
||||
local c = require('nuwiki.config').wiki_cfg(0)
|
||||
local idx_line = ah_line(root .. c.diary_rel .. '/' .. c.diary_idx .. '.wiki')
|
||||
record(idx_line == '', 'cmd.auto_header_skips_diary_index', 'line1=' .. idx_line)
|
||||
end
|
||||
|
||||
-- ===== Lists =====
|
||||
|
||||
run('list.toggle_via_C-Space', {
|
||||
@@ -656,6 +735,16 @@ vim.defer_fn(function()
|
||||
expect_lines = { '| a | b |', '|x | |' },
|
||||
wait_ms = 100,
|
||||
})
|
||||
run('cr.table_link_cell_pipe_not_a_separator', {
|
||||
-- Regression: the `|` inside `[[u|t]]` must NOT split the cell, so the
|
||||
-- link stays intact and the row keeps its 2 columns (a 2-col fresh row,
|
||||
-- not the mangled 3-col one the old naive `|` scan produced).
|
||||
lines = { '| a | [[u|t]] |' },
|
||||
cursor = { 1, 4 },
|
||||
keys = 'A<CR>x<Esc>',
|
||||
expect_lines = { '| a | [[u|t]] |', '|x | |' },
|
||||
wait_ms = 100,
|
||||
})
|
||||
|
||||
-- ===== Insert-mode table navigation (Cluster 6) =====
|
||||
|
||||
@@ -716,6 +805,338 @@ vim.defer_fn(function()
|
||||
-- a populated diary, but at least confirm the maps fire without
|
||||
-- error.
|
||||
|
||||
-- ===== Colorize commands pass their {color} arg (regression) =====
|
||||
-- :VimwikiColorize / :NuwikiColorize are -nargs=1; the Neovim defs used to
|
||||
-- call colorize() without <q-args>, silently dropping the colour and
|
||||
-- prompting instead. Run the real commands and assert the colour lands.
|
||||
tobj_case('cmd.VimwikiColorize_uses_arg', function()
|
||||
set_buf({ 'word here' })
|
||||
vim.api.nvim_win_set_cursor(0, { 1, 0 })
|
||||
vim.cmd('VimwikiColorize red')
|
||||
local got = vim.api.nvim_get_current_line()
|
||||
if got ~= '<span style="color:red">word</span> here' then
|
||||
error('VimwikiColorize dropped its arg: ' .. got)
|
||||
end
|
||||
end)
|
||||
tobj_case('cmd.NuwikiColorize_uses_arg', function()
|
||||
set_buf({ 'second word' })
|
||||
vim.api.nvim_win_set_cursor(0, { 1, 0 })
|
||||
vim.cmd('NuwikiColorize #00ff00')
|
||||
local got = vim.api.nvim_get_current_line()
|
||||
if got ~= '<span style="color:#00ff00">second</span> word' then
|
||||
error('NuwikiColorize dropped its arg: ' .. got)
|
||||
end
|
||||
end)
|
||||
-- Establish a stale visual selection, then confirm the normal-mode command
|
||||
-- still wraps the cword (not the leftover selection) — the visualmode()
|
||||
-- heuristic regression.
|
||||
tobj_case('cmd.Colorize_ignores_stale_visual_selection', function()
|
||||
set_buf({ 'alpha beta gamma' })
|
||||
vim.cmd('normal! 0vee\27') -- select 'alpha beta', leave marks behind
|
||||
vim.api.nvim_win_set_cursor(0, { 1, 11 }) -- on 'gamma'
|
||||
vim.cmd('VimwikiColorize blue')
|
||||
local got = vim.api.nvim_get_current_line()
|
||||
if got ~= 'alpha beta <span style="color:blue">gamma</span>' then
|
||||
error('command wrapped stale selection instead of cword: ' .. got)
|
||||
end
|
||||
end)
|
||||
-- The visual mapping path (explicit visual=true) wraps the selection.
|
||||
tobj_case('colorize.visual_wraps_selection', function()
|
||||
set_buf({ 'one two three' })
|
||||
vim.cmd('normal! 0wviw\27') -- visually select 'two', marks set on exit
|
||||
require('nuwiki.commands').colorize('red', true)
|
||||
local got = vim.api.nvim_get_current_line()
|
||||
if got ~= 'one <span style="color:red">two</span> three' then
|
||||
error('visual colorize did not wrap selection: ' .. got)
|
||||
end
|
||||
end)
|
||||
-- Invoking the command with a range (`:'<,'>VimwikiColorize`, as Vim builds
|
||||
-- it when you select then type the command) must not error (-range) and
|
||||
-- wraps the selection.
|
||||
tobj_case('cmd.Colorize_range_wraps_selection', function()
|
||||
set_buf({ 'pick this word' })
|
||||
vim.cmd('normal! 0wviw\27') -- select 'this'
|
||||
vim.cmd("'<,'>VimwikiColorize orange")
|
||||
local got = vim.api.nvim_get_current_line()
|
||||
if got ~= 'pick <span style="color:orange">this</span> word' then
|
||||
error('ranged command did not wrap selection: ' .. got)
|
||||
end
|
||||
end)
|
||||
-- Colour spans are concealed down to their coloured text. Put the span on a
|
||||
-- non-cursor line so concealcursor (=c) doesn't reveal it, then refresh.
|
||||
tobj_case('colorize.conceal_hides_tags_shows_text', function()
|
||||
set_buf({ 'plain line', '<span style="color:red">word</span> tail' })
|
||||
vim.api.nvim_win_set_cursor(0, { 1, 0 })
|
||||
vim.fn['nuwiki#colors#refresh']()
|
||||
local l = vim.fn.getline(2)
|
||||
local wcol = (l:find('word')) -- 1-based
|
||||
local open = vim.fn.synconcealed(2, 1)[1]
|
||||
local close = vim.fn.synconcealed(2, (l:find('</span>')))[1]
|
||||
local word = vim.fn.synconcealed(2, wcol)[1]
|
||||
local grp = vim.fn.synIDattr(vim.fn.synID(2, wcol, 1), 'name')
|
||||
if not (open == 1 and close == 1 and word == 0 and grp == 'nuwikiColorSpan_red') then
|
||||
error(string.format('open=%s close=%s word=%s grp=%s', open, close, word, grp))
|
||||
end
|
||||
end)
|
||||
|
||||
-- ===== Visual-range list commands + normalize-visual (regression) =====
|
||||
-- :N,MVimwikiToggleListItem toggles every checkbox in the range (server,
|
||||
-- one request per line; wait for the edits to land).
|
||||
tobj_case('cmd.toggle_list_item_range', function()
|
||||
set_buf({ '- [ ] a', '- [ ] b', '- [ ] c' })
|
||||
vim.cmd('1,3VimwikiToggleListItem')
|
||||
local done = vim.wait(2000, function()
|
||||
for _, l in ipairs(vim.api.nvim_buf_get_lines(0, 0, -1, false)) do
|
||||
if not l:match('%[[xX]%]') then return false end
|
||||
end
|
||||
return true
|
||||
end, 30)
|
||||
if not done then
|
||||
error('range toggle incomplete: ' .. vim.inspect(vim.api.nvim_buf_get_lines(0, 0, -1, false)))
|
||||
end
|
||||
end)
|
||||
-- `:1,3VimwikiToggleRejectedListItem` — now -range (was bare → E481 on a
|
||||
-- ranged invocation, and cursor-only). Each line should gain a rejected
|
||||
-- checkbox `[-]`.
|
||||
tobj_case('cmd.reject_list_item_range', function()
|
||||
set_buf({ '- [ ] a', '- [ ] b', '- [ ] c' })
|
||||
vim.cmd('1,3VimwikiToggleRejectedListItem')
|
||||
local done = vim.wait(2000, function()
|
||||
for _, l in ipairs(vim.api.nvim_buf_get_lines(0, 0, -1, false)) do
|
||||
if not l:match('%[%-%]') then return false end
|
||||
end
|
||||
return true
|
||||
end, 30)
|
||||
if not done then
|
||||
error('range reject incomplete: ' .. vim.inspect(vim.api.nvim_buf_get_lines(0, 0, -1, false)))
|
||||
end
|
||||
end)
|
||||
-- `:1,2VimwikiRemoveDone` — now -range (was -bang only → E481 on a ranged
|
||||
-- invocation). Only done items within the range are removed; line-2 `[X]`
|
||||
-- (0-indexed) outside [0,1] survives.
|
||||
tobj_case('cmd.remove_done_range', function()
|
||||
set_buf({ '- [X] a', '- [ ] b', '- [X] c' })
|
||||
vim.cmd('1,2VimwikiRemoveDone')
|
||||
local done = vim.wait(2000, function()
|
||||
local ls = vim.api.nvim_buf_get_lines(0, 0, -1, false)
|
||||
-- line-0 done item gone (2 lines left), line-2 done item still present.
|
||||
return #ls == 2 and ls[#ls]:match('%[X%] c')
|
||||
end, 30)
|
||||
if not done then
|
||||
error('ranged remove-done wrong: ' .. vim.inspect(vim.api.nvim_buf_get_lines(0, 0, -1, false)))
|
||||
end
|
||||
end)
|
||||
-- `:VimwikiTable {cols} [rows]` — now -nargs=* (was -nargs=1, which dropped
|
||||
-- the args entirely). A 4-col table has 5 pipes per row; 3 data rows give
|
||||
-- header + separator + 3 = 5 table lines.
|
||||
tobj_case('cmd.VimwikiTable_passes_cols_and_rows', function()
|
||||
set_buf({ '' })
|
||||
vim.api.nvim_win_set_cursor(0, { 1, 0 })
|
||||
vim.cmd('VimwikiTable 4 3')
|
||||
local tbl = {}
|
||||
local done = vim.wait(2000, function()
|
||||
tbl = {}
|
||||
for _, l in ipairs(vim.api.nvim_buf_get_lines(0, 0, -1, false)) do
|
||||
if l:find('|', 1, true) then tbl[#tbl + 1] = l end
|
||||
end
|
||||
return #tbl >= 5
|
||||
end, 30)
|
||||
if not done then
|
||||
error('table not inserted: ' .. vim.inspect(vim.api.nvim_buf_get_lines(0, 0, -1, false)))
|
||||
end
|
||||
local pipes = select(2, tbl[1]:gsub('|', ''))
|
||||
if pipes ~= 5 then
|
||||
error('want 4 cols (5 pipes), got ' .. pipes .. ': ' .. tbl[1])
|
||||
end
|
||||
if #tbl ~= 5 then
|
||||
error('want 5 table lines (hdr+sep+3 rows), got ' .. #tbl)
|
||||
end
|
||||
end)
|
||||
-- The Neovim :Vimwiki*Link Ex-commands used to dispatch to raw
|
||||
-- vim.lsp.buf.definition(), bypassing the bare-word → [[link]] wrap that the
|
||||
-- Vim branch and the <CR> mapping do. They now route through
|
||||
-- follow_link_or_create; the wrap is a pure client-side buffer edit (no LSP),
|
||||
-- so it's the headlessly-reliable observable.
|
||||
tobj_case('cmd.VimwikiFollowLink_wraps_bare_word', function()
|
||||
set_buf({ 'word here' })
|
||||
vim.api.nvim_win_set_cursor(0, { 1, 0 })
|
||||
vim.cmd('VimwikiFollowLink')
|
||||
local got = vim.api.nvim_get_current_line()
|
||||
if got ~= '[[word]] here' then
|
||||
error('VimwikiFollowLink did not wrap bare word: ' .. got)
|
||||
end
|
||||
end)
|
||||
tobj_case('cmd.VimwikiSplitLink_wraps_bare_word', function()
|
||||
set_buf({ 'word here' })
|
||||
vim.api.nvim_win_set_cursor(0, { 1, 0 })
|
||||
vim.cmd('VimwikiSplitLink')
|
||||
local got = vim.api.nvim_get_current_line()
|
||||
vim.cmd('only') -- drop the split this command opened
|
||||
if got ~= '[[word]] here' then
|
||||
error('VimwikiSplitLink did not wrap bare word: ' .. got)
|
||||
end
|
||||
end)
|
||||
-- `:[range]VimwikiListChangeLvl {dir} [plus_children]` — now -range -nargs=+
|
||||
-- (was -nargs=?, range-less). A 1,3 range with `increase` must indent the
|
||||
-- items that have somewhere to nest (the 2nd item under the 1st).
|
||||
tobj_case('cmd.ListChangeLvl_range_indents', function()
|
||||
set_buf({ '- a', '- b', '- c' })
|
||||
vim.cmd('1,3VimwikiListChangeLvl increase 0')
|
||||
local done = vim.wait(2000, function()
|
||||
local l = vim.api.nvim_buf_get_lines(0, 0, -1, false)
|
||||
return l[2] ~= nil and l[2]:match('^%s%s*%-') ~= nil
|
||||
end, 30)
|
||||
if not done then
|
||||
error('range list-change-lvl did not indent: '
|
||||
.. vim.inspect(vim.api.nvim_buf_get_lines(0, 0, -1, false)))
|
||||
end
|
||||
end)
|
||||
-- `:[range]VimwikiChangeSymbolTo {sym}` — now -range -nargs=1. A 1,3 range
|
||||
-- must set every item's marker to the given symbol.
|
||||
tobj_case('cmd.ChangeSymbolTo_range_sets_marker', function()
|
||||
set_buf({ '- a', '- b', '- c' })
|
||||
vim.cmd('1,3VimwikiChangeSymbolTo *')
|
||||
local done = vim.wait(2000, function()
|
||||
local l = vim.api.nvim_buf_get_lines(0, 0, -1, false)
|
||||
for _, ln in ipairs(l) do
|
||||
if not ln:match('^%*%s') then return false end
|
||||
end
|
||||
return true
|
||||
end, 30)
|
||||
if not done then
|
||||
error('range change-symbol did not set all markers: '
|
||||
.. vim.inspect(vim.api.nvim_buf_get_lines(0, 0, -1, false)))
|
||||
end
|
||||
end)
|
||||
-- `:VimwikiGenerateLinks [rel_path]` — now -nargs=? (was bare; an arg raised
|
||||
-- E488). Both forms must dispatch without a Vim-level command error.
|
||||
tobj_case('cmd.GenerateLinks_accepts_optional_path', function()
|
||||
set_buf({ '= page =' })
|
||||
local ok1 = pcall(vim.cmd, 'VimwikiGenerateLinks')
|
||||
local ok2 = pcall(vim.cmd, 'VimwikiGenerateLinks subdir')
|
||||
if not (ok1 and ok2) then
|
||||
error('GenerateLinks rejected an arg: ok1=' .. tostring(ok1) .. ' ok2=' .. tostring(ok2))
|
||||
end
|
||||
end)
|
||||
-- :NuwikiGenerateTags alias must exist (parity with :VimwikiGenerateTags).
|
||||
tobj_case('cmd.NuwikiGenerateTags_exists', function()
|
||||
if vim.fn.exists(':NuwikiGenerateTags') ~= 2 then
|
||||
error(':NuwikiGenerateTags not defined')
|
||||
end
|
||||
end)
|
||||
-- Diary-note family carries -count=0 (vimwiki's wiki selector), so
|
||||
-- `:2VimwikiMakeDiaryNote` selects wiki #2 instead of raising E481.
|
||||
tobj_case('cmd.VimwikiMakeDiaryNote_has_count', function()
|
||||
local c = vim.api.nvim_buf_get_commands(0, {}).VimwikiMakeDiaryNote
|
||||
if not c then error(':VimwikiMakeDiaryNote not defined') end
|
||||
if c.count == nil or c.count == false then
|
||||
error('VimwikiMakeDiaryNote missing -count: ' .. vim.inspect(c))
|
||||
end
|
||||
end)
|
||||
-- `:VimwikiNormalizeLink 1` (the arg is upstream's visual flag; the command
|
||||
-- is -nargs=? not -range, so no `'<,'>`) wraps the last visual selection.
|
||||
tobj_case('cmd.normalize_link_visual_wraps_selection', function()
|
||||
set_buf({ 'one two three' })
|
||||
vim.cmd('normal! 0wviw\27') -- select 'two', leave visual (sets '<,'> marks)
|
||||
vim.cmd('VimwikiNormalizeLink 1')
|
||||
local got = vim.api.nvim_get_current_line()
|
||||
if got ~= 'one [[two]] three' then
|
||||
error('visual normalize did not wrap selection: ' .. got)
|
||||
end
|
||||
end)
|
||||
|
||||
-- Visual <CR> mapping wraps the selection (upstream parity with visual `+`).
|
||||
-- Select via `normal!` (sets the '<,'> marks reliably), then `gv` reselects
|
||||
-- and the fed <CR> fires the x-mode mapping.
|
||||
tobj_case('map.visual_cr_wraps_selection', function()
|
||||
set_buf({ 'alpha beta gamma' })
|
||||
vim.cmd('normal! 0wviw\27') -- select 'beta', leave visual → marks set
|
||||
send('gv<CR>')
|
||||
local got = vim.api.nvim_get_current_line()
|
||||
if got ~= 'alpha [[beta]] gamma' then
|
||||
error('visual <CR> did not wrap selection: ' .. got)
|
||||
end
|
||||
end)
|
||||
|
||||
-- Insert <S-CR> continues a list item with no marker, aligned under the text.
|
||||
-- Test the `<expr>` handler directly — `<S-CR>` doesn't round-trip through
|
||||
-- headless feedkeys, but the handler is what the mapping invokes.
|
||||
tobj_case('map.insert_shift_cr_multiline', function()
|
||||
local cr = vim.api.nvim_replace_termcodes('<CR>', true, false, true)
|
||||
set_buf({ '- item' }) -- "- " is 2 cols → continuation aligns at column 2
|
||||
local got = require('nuwiki.commands').smart_shift_return()
|
||||
if got ~= cr .. ' ' then
|
||||
error('S-CR (bullet) wrong: ' .. vim.inspect(got))
|
||||
end
|
||||
set_buf({ '- [ ] item' }) -- "- [ ] " is 6 cols
|
||||
got = require('nuwiki.commands').smart_shift_return()
|
||||
if got ~= cr .. ' ' then
|
||||
error('S-CR (checkbox) wrong: ' .. vim.inspect(got))
|
||||
end
|
||||
set_buf({ 'plain text' }) -- not a list → plain <CR>
|
||||
got = require('nuwiki.commands').smart_shift_return()
|
||||
if got ~= cr then
|
||||
error('S-CR (non-list) should be plain CR: ' .. vim.inspect(got))
|
||||
end
|
||||
end)
|
||||
|
||||
-- ===== Configurable map_prefix (vimwiki g:vimwiki_map_prefix) =====
|
||||
-- Overriding map_prefix must relocate the whole <prefix>* family. We attach
|
||||
-- to a throwaway scratch buffer with a custom prefix and assert the new
|
||||
-- bindings exist there while the default <Leader>w* ones do not.
|
||||
tobj_case('map_prefix.relocates_wiki_family', function()
|
||||
local config = require('nuwiki.config')
|
||||
local keymaps = require('nuwiki.keymaps')
|
||||
local saved = config.options.map_prefix
|
||||
config.options.map_prefix = '<Leader>n'
|
||||
local buf = vim.api.nvim_create_buf(false, true)
|
||||
vim.api.nvim_set_current_buf(buf)
|
||||
local ok, err = pcall(function()
|
||||
keymaps.attach(buf, { enabled = true, wiki_prefix = true, links = true, html_export = true })
|
||||
local function bmap(lhs)
|
||||
local d = vim.fn.maparg(lhs, 'n', false, true)
|
||||
return not vim.tbl_isempty(d) and d.buffer == 1
|
||||
end
|
||||
for _, lhs in ipairs({
|
||||
'<Leader>nw', '<Leader>nt', '<Leader>n<Leader>w', '<Leader>nr', '<Leader>nh', '<Leader>nha',
|
||||
}) do
|
||||
if not bmap(lhs) then error('relocated map missing: ' .. lhs) end
|
||||
end
|
||||
if bmap('<Leader>ww') then error('<Leader>ww still bound under custom prefix') end
|
||||
end)
|
||||
-- Always restore global state so later cases see the default prefix.
|
||||
config.options.map_prefix = saved
|
||||
vim.api.nvim_buf_delete(buf, { force = true })
|
||||
if not ok then error(err) end
|
||||
end)
|
||||
|
||||
-- ===== Global (non-buffer) entry-point diary maps =====
|
||||
-- init.lua _setup_global_mappings ran during setup(). Regression guard for
|
||||
-- the audit-found bug: <prefix><Leader>t must be today-in-a-tab and
|
||||
-- <prefix><Leader>m must be tomorrow (matching the Vim global side +
|
||||
-- upstream), not the old wiring that dropped `m` and bound `t` to tomorrow.
|
||||
-- Query in a scratch (non-vimwiki) buffer so the buffer-local maps don't
|
||||
-- shadow the globals.
|
||||
tobj_case('global_maps.diary_tab_and_tomorrow_targets', function()
|
||||
local buf = vim.api.nvim_create_buf(false, true)
|
||||
vim.api.nvim_set_current_buf(buf)
|
||||
local function gdesc(lhs)
|
||||
local d = vim.fn.maparg(lhs, 'n', false, true)
|
||||
if vim.tbl_isempty(d) then return nil end
|
||||
return d.desc or ''
|
||||
end
|
||||
local ok, err = pcall(function()
|
||||
local t = gdesc('<Leader>w<Leader>t')
|
||||
local m = gdesc('<Leader>w<Leader>m')
|
||||
if not t then error('<Leader>w<Leader>t global map missing') end
|
||||
if not m then error('<Leader>w<Leader>m global map missing (tomorrow)') end
|
||||
if not t:find('tab') then error('<Leader>w<Leader>t should be today-in-tab, desc=' .. t) end
|
||||
if not m:find('tomorrow') then error('<Leader>w<Leader>m should be tomorrow, desc=' .. m) end
|
||||
end)
|
||||
vim.api.nvim_buf_delete(buf, { force = true })
|
||||
if not ok then error(err) end
|
||||
end)
|
||||
|
||||
-- ===== Wiki picker (config-driven, no LSP) =====
|
||||
-- The picker must build its list from config so it works from any buffer
|
||||
-- before the server attaches.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
#
|
||||
# The Lua harness needs a real LSP attachment (most keymaps dispatch
|
||||
# `workspace/executeCommand`), so we:
|
||||
# 1. ensure `bin/nuwiki-ls` is built (release),
|
||||
# 1. download `bin/nuwiki-ls` from the nuwiki-rs releases,
|
||||
# 2. spin up a scratch wiki under a temp dir,
|
||||
# 3. invoke `nvim --headless` with a minimal init that loads the
|
||||
# plugin + calls `setup()`, then sources the harness Lua.
|
||||
@@ -20,15 +20,34 @@ trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
log() { printf '\033[1;34m[keymap-test]\033[0m %s\n' "$*"; }
|
||||
|
||||
# Always rebuild the LSP binary so harness runs against current sources.
|
||||
# A previous version only built when the symlink was missing, which let
|
||||
# stale binaries pass tests against unrelated code — incremental cargo
|
||||
# build is fast, so just always run it.
|
||||
# Download the LSP binary from nuwiki-rs releases (falls back to cargo build).
|
||||
BIN="$REPO_ROOT/bin/nuwiki-ls"
|
||||
log 'building nuwiki-ls (release, incremental)…'
|
||||
cargo build --release -p nuwiki-ls --manifest-path "$REPO_ROOT/Cargo.toml" >/dev/null
|
||||
mkdir -p "$REPO_ROOT/bin"
|
||||
ln -sf "$REPO_ROOT/target/release/nuwiki-ls" "$BIN"
|
||||
log 'downloading nuwiki-ls…'
|
||||
if [[ ! -x "$BIN" ]]; then
|
||||
vim -e -s -c "source $REPO_ROOT/scripts/download_bin.vim" -c "q" >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
# If download failed, fall back to cargo build from nuwiki-rs.
|
||||
if [[ ! -x "$BIN" ]]; then
|
||||
log "download failed, falling back to cargo build from nuwiki-rs…"
|
||||
rs_repo="$REPO_ROOT/nuwiki-rs"
|
||||
if [[ ! -f "$rs_repo/Cargo.toml" ]]; then
|
||||
log "cloning nuwiki-rs …"
|
||||
git clone --depth 1 https://code.gfran.co/gffranco/nuwiki-rs.git "$rs_repo"
|
||||
fi
|
||||
cargo build --release -p nuwiki-ls --manifest-path "$rs_repo/Cargo.toml" >/dev/null
|
||||
if [[ ! -f "$rs_repo/target/release/nuwiki-ls" ]]; then
|
||||
echo 'FAIL: could not build nuwiki-ls binary' >&2
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p "$REPO_ROOT/bin"
|
||||
ln -sf "$rs_repo/target/release/nuwiki-ls" "$BIN"
|
||||
fi
|
||||
|
||||
if [[ ! -x "$BIN" ]]; then
|
||||
echo 'FAIL: could not obtain nuwiki-ls binary' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$TMP/wiki"
|
||||
echo "= seed =" > "$TMP/wiki/index.wiki"
|
||||
@@ -39,8 +58,14 @@ cat > "$INIT" <<EOF
|
||||
vim.opt.runtimepath:prepend('$REPO_ROOT')
|
||||
vim.g.nuwiki_binary_path = '$BIN'
|
||||
-- Enable the opt-in mouse mappings so the harness can assert the full
|
||||
-- documented mapping surface (doc §6 mouse group).
|
||||
require('nuwiki').setup({ wiki_root = '$TMP/wiki', mappings = { mouse = true } })
|
||||
-- documented mapping surface (doc §6 mouse group). table_auto_fmt is off here
|
||||
-- so its async on-InsertLeave re-align doesn't perturb the deterministic
|
||||
-- table-nav keystroke cases (its wiring is verified on a scratch buffer).
|
||||
require('nuwiki').setup({
|
||||
wiki_root = '$TMP/wiki',
|
||||
mappings = { mouse = true },
|
||||
table_auto_fmt = false,
|
||||
})
|
||||
EOF
|
||||
|
||||
RESULTS="$TMP/results.txt"
|
||||
|
||||
Executable
+67
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# development/tests/test-vars-vim.sh — exercises the vimwiki compatibility
|
||||
# shim (autoload/vimwiki/vars.vim) that third-party plugins (vimwiki-sync,
|
||||
# vim-zettel) call via vimwiki#vars#get_wikilocal.
|
||||
#
|
||||
# Runs test-vars-vim.vim under headless Vim; that script writes PASS/FAIL
|
||||
# lines to $NUWIKI_VARS_OUT. Covers per-wiki-index resolution, global
|
||||
# fallback, out-of-range clamp, the no-index "owning wiki" path (the
|
||||
# BufRead path that previously raised E121), and the single-wiki shorthand.
|
||||
#
|
||||
# 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[vars-vim]\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-vars-vim-test-XXXXXX)"
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
DIR_A="$TMP/wiki_a"
|
||||
DIR_B="$TMP/wiki_b"
|
||||
mkdir -p "$DIR_A" "$DIR_B"
|
||||
printf '= Home =\n' > "$DIR_A/index.wiki"
|
||||
printf '= B =\n' > "$DIR_B/index.wiki"
|
||||
|
||||
OUT="$TMP/vars.out"
|
||||
|
||||
VIMRC="$TMP/vimrc"
|
||||
cat > "$VIMRC" <<EOF
|
||||
set nocompatible
|
||||
let &runtimepath = '$REPO_ROOT' . ',' . &runtimepath
|
||||
EOF
|
||||
|
||||
log 'running Vim vars-shim harness…'
|
||||
NUWIKI_VARS_OUT="$OUT" \
|
||||
NUWIKI_VARS_DIR_A="$DIR_A" \
|
||||
NUWIKI_VARS_DIR_B="$DIR_B" \
|
||||
timeout 30 vim -e -s -u "$VIMRC" \
|
||||
-c "source $REPO_ROOT/development/tests/test-vars-vim.vim" \
|
||||
</dev/null >"$TMP/vim.log" 2>&1 || true
|
||||
|
||||
if [[ ! -f "$OUT" ]]; then
|
||||
echo 'vars 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 'vars shim OK ✓'
|
||||
exit 0
|
||||
@@ -0,0 +1,57 @@
|
||||
" development/tests/test-vars-vim.vim — exercises the vimwiki compat shim
|
||||
" (autoload/vimwiki/vars.vim) that third-party plugins call.
|
||||
"
|
||||
" Sourced by test-vars-vim.sh under headless Vim. Writes PASS/FAIL lines to
|
||||
" $NUWIKI_VARS_OUT; the shell wrapper fails on any FAIL (or missing output).
|
||||
|
||||
let s:out = []
|
||||
let s:dir_a = $NUWIKI_VARS_DIR_A
|
||||
let s:dir_b = $NUWIKI_VARS_DIR_B
|
||||
|
||||
function! s:check(desc, got, want) abort
|
||||
if a:got ==# a:want
|
||||
call add(s:out, 'PASS ' . a:desc)
|
||||
else
|
||||
call add(s:out, 'FAIL ' . a:desc . ' got=[' . a:got . '] want=[' . a:want . ']')
|
||||
endif
|
||||
endfunction
|
||||
|
||||
" ----- multi-wiki: explicit index -----
|
||||
let g:nuwiki_wikis = [
|
||||
\ {'root': s:dir_a, 'file_extension': 'wiki', 'syntax': 'markdown'},
|
||||
\ {'root': s:dir_b, 'file_extension': '.wiki'},
|
||||
\ ]
|
||||
let g:nuwiki_syntax = 'vimwiki'
|
||||
|
||||
call s:check('idx0 path', vimwiki#vars#get_wikilocal('path', 0), s:dir_a . '/')
|
||||
call s:check('idx0 ext', vimwiki#vars#get_wikilocal('ext', 0), '.wiki')
|
||||
call s:check('idx0 syntax', vimwiki#vars#get_wikilocal('syntax', 0), 'markdown')
|
||||
" wiki[1] doesn't set syntax → global default
|
||||
call s:check('idx1 path', vimwiki#vars#get_wikilocal('path', 1), s:dir_b . '/')
|
||||
call s:check('idx1 syntax', vimwiki#vars#get_wikilocal('syntax', 1), 'vimwiki')
|
||||
" out-of-range clamps to first wiki
|
||||
call s:check('idx9 clamp', vimwiki#vars#get_wikilocal('path', 9), s:dir_a . '/')
|
||||
" misc keys
|
||||
call s:check('is_temporary', vimwiki#vars#get_wikilocal('is_temporary_wiki', 0) . '', '0')
|
||||
call s:check('unknown key', vimwiki#vars#get_wikilocal('bogus', 0), '')
|
||||
|
||||
" ----- no index: resolve the wiki owning the current buffer -----
|
||||
" (this is the BufRead path; regression guard for the s:resolve_index E121.)
|
||||
execute 'edit ' . fnameescape(s:dir_b . '/index.wiki')
|
||||
call s:check('noarg owning wiki_b', vimwiki#vars#get_wikilocal('path'), s:dir_b . '/')
|
||||
execute 'edit ' . fnameescape(s:dir_a . '/index.wiki')
|
||||
call s:check('noarg owning wiki_a', vimwiki#vars#get_wikilocal('path'), s:dir_a . '/')
|
||||
" buffer outside any wiki → first wiki
|
||||
enew
|
||||
call s:check('noarg outside→first', vimwiki#vars#get_wikilocal('path'), s:dir_a . '/')
|
||||
|
||||
" ----- single-wiki shorthand (no g:nuwiki_wikis) -----
|
||||
unlet g:nuwiki_wikis
|
||||
let g:nuwiki_wiki_root = s:dir_a
|
||||
let g:nuwiki_file_extension = 'txt'
|
||||
enew
|
||||
call s:check('shorthand path', vimwiki#vars#get_wikilocal('path'), s:dir_a . '/')
|
||||
call s:check('shorthand ext', vimwiki#vars#get_wikilocal('ext'), '.txt')
|
||||
call s:check('shorthand syntax', vimwiki#vars#get_wikilocal('syntax'), 'vimwiki')
|
||||
|
||||
call writefile(s:out, $NUWIKI_VARS_OUT)
|
||||
Executable
+59
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# development/tests/test-vimwiki-compat-vim.sh — upstream-vimwiki config
|
||||
# drop-in compatibility for the Vim client.
|
||||
#
|
||||
# Runs test-vimwiki-compat-vim.vim under headless Vim; that script sets an
|
||||
# upstream `g:vimwiki_list` + `g:vimwiki_*` config and asserts
|
||||
# nuwiki#lsp#settings() translates it into the server's schema (per-wiki key
|
||||
# remap + global scalar settings folded into each wiki). Also checks that
|
||||
# nuwiki-native config takes precedence.
|
||||
#
|
||||
# Exit code: 0 when every check passes, 1 on any FAIL or missing output.
|
||||
# Skips (exit 0) when vim is not installed.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
|
||||
log() { printf '\033[1;34m[vimwiki-compat]\033[0m %s\n' "$*"; }
|
||||
|
||||
if ! command -v vim >/dev/null 2>&1; then
|
||||
log 'vim not installed — skipping'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
TMP="$(mktemp -d -t nuwiki-vwc-test-XXXXXX)"
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
OUT="$TMP/vwc.out"
|
||||
|
||||
VIMRC="$TMP/vimrc"
|
||||
cat > "$VIMRC" <<EOF
|
||||
set nocompatible
|
||||
let &runtimepath = '$REPO_ROOT' . ',' . &runtimepath
|
||||
EOF
|
||||
|
||||
log 'running Vim vimwiki-compat harness…'
|
||||
NUWIKI_VWC_OUT="$OUT" \
|
||||
timeout 30 vim -e -s -u "$VIMRC" \
|
||||
-c "source $REPO_ROOT/development/tests/test-vimwiki-compat-vim.vim" \
|
||||
</dev/null >"$TMP/vim.log" 2>&1 || true
|
||||
|
||||
if [[ ! -f "$OUT" ]]; then
|
||||
echo 'vimwiki-compat harness produced no output' >&2
|
||||
echo '--- vim log ---' >&2
|
||||
cat "$TMP/vim.log" >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cat "$OUT"
|
||||
PASSED="$(grep -c '^PASS ' "$OUT" || true)"
|
||||
FAILED="$(grep -c '^FAIL ' "$OUT" || true)"
|
||||
echo "SUMMARY: ${PASSED} passed, ${FAILED} failed"
|
||||
|
||||
if [[ "$FAILED" -ne 0 ]]; then
|
||||
exit 1
|
||||
fi
|
||||
log 'vimwiki config compat OK ✓'
|
||||
exit 0
|
||||
@@ -0,0 +1,86 @@
|
||||
" development/tests/test-vimwiki-compat-vim.vim — upstream-vimwiki config
|
||||
" drop-in compatibility for the Vim client.
|
||||
"
|
||||
" Sourced by test-vimwiki-compat-vim.sh under headless Vim. Sets an upstream
|
||||
" `g:vimwiki_list` + `g:vimwiki_*` config and asserts nuwiki#lsp#settings()
|
||||
" translates it into the server's schema. Writes PASS/FAIL to
|
||||
" $NUWIKI_VWC_OUT; the wrapper fails on any FAIL.
|
||||
|
||||
let s:out = []
|
||||
function! s:check(desc, cond) abort
|
||||
call add(s:out, (a:cond ? 'PASS ' : 'FAIL ') . a:desc)
|
||||
endfunction
|
||||
|
||||
" ----- upstream vimwiki config (no nuwiki-native vars set) -----
|
||||
let g:vimwiki_toc_header_level = 2
|
||||
let g:vimwiki_html_header_numbering = 2
|
||||
let g:vimwiki_html_header_numbering_sym = ' -'
|
||||
let s:personal = {}
|
||||
let s:personal.path = '~/.vimwiki/personal_wiki'
|
||||
let s:personal.path_html = '~/public_html/personal_wiki'
|
||||
let s:personal.template_default = 'default'
|
||||
let s:personal.template_ext = '.tpl'
|
||||
let s:personal.auto_export = 1
|
||||
let s:ifood = {}
|
||||
let s:ifood.path = '~/.vimwiki/ifood_wiki'
|
||||
let g:vimwiki_list = [s:personal, s:ifood]
|
||||
|
||||
let s:cfg = nuwiki#lsp#settings()
|
||||
|
||||
call s:check('has wikis list', has_key(s:cfg, 'wikis'))
|
||||
call s:check('two wikis', has_key(s:cfg, 'wikis') && len(s:cfg.wikis) == 2)
|
||||
let s:w0 = get(s:cfg, 'wikis', [{}])[0]
|
||||
let s:w1 = get(s:cfg, 'wikis', [{},{}])[1]
|
||||
call s:check('w0 path->root', get(s:w0, 'root', '') ==# '~/.vimwiki/personal_wiki')
|
||||
call s:check('w0 path_html->html_path', get(s:w0, 'html_path', '') ==# '~/public_html/personal_wiki')
|
||||
call s:check('w0 template_default', get(s:w0, 'template_default', '') ==# 'default')
|
||||
call s:check('w0 auto_export', get(s:w0, 'auto_export', 0) == 1)
|
||||
call s:check('w0 toc_header_level=2', get(s:w0, 'toc_header_level', 0) == 2)
|
||||
call s:check('w0 html_numbering=2', get(s:w0, 'html_header_numbering', 0) == 2)
|
||||
call s:check('w0 numbering_sym', get(s:w0, 'html_header_numbering_sym', '') ==# ' -')
|
||||
call s:check('w1 root', get(s:w1, 'root', '') ==# '~/.vimwiki/ifood_wiki')
|
||||
call s:check('w1 globals applied', get(s:w1, 'toc_header_level', 0) == 2)
|
||||
|
||||
" The buffer-side commands (e.g. <Leader>ww) must resolve the SAME wikis from
|
||||
" g:vimwiki_list — not just the LSP payload. Regression guard for "<Leader>ww
|
||||
" opens an empty ~/vimwiki".
|
||||
let s:wl = nuwiki#commands#wiki_list()
|
||||
call s:check('wiki_list sees vimwiki config', len(s:wl) == 2)
|
||||
call s:check('wiki_list w0 root', len(s:wl) >= 1 && s:wl[0].root ==# expand('~/.vimwiki/personal_wiki'))
|
||||
call s:check('wiki_list w1 root', len(s:wl) >= 2 && s:wl[1].root ==# expand('~/.vimwiki/ifood_wiki'))
|
||||
|
||||
" ----- nuwiki-native config wins over vimwiki -----
|
||||
let g:nuwiki_wikis = [{'root': '~/native', 'name': 'native'}]
|
||||
let s:cfg2 = nuwiki#lsp#settings()
|
||||
call s:check('native g:nuwiki_wikis wins',
|
||||
\ len(get(s:cfg2, 'wikis', [])) == 1
|
||||
\ && get(s:cfg2.wikis[0], 'root', '') ==# '~/native')
|
||||
unlet g:nuwiki_wikis
|
||||
|
||||
" ----- nuwiki-native scalar overrides the vimwiki global -----
|
||||
let g:vimwiki_list = []
|
||||
let g:nuwiki_toc_header_level = 4
|
||||
let s:cfg3 = nuwiki#lsp#settings()
|
||||
" No wikis list now (vimwiki_list empty) → scalar folded into top-level.
|
||||
call s:check('native scalar override', get(s:cfg3, 'toc_header_level', 0) == 4)
|
||||
unlet g:nuwiki_toc_header_level
|
||||
|
||||
" ----- global shorthand on native g:nuwiki_wikis (vimwiki-style) -----
|
||||
" A global default applies to every wiki; a per-wiki value overrides it.
|
||||
let g:nuwiki_toc_header_level = 2
|
||||
let g:nuwiki_html_header_numbering = 2
|
||||
let g:nuwiki_wikis = [
|
||||
\ {'root': '~/w1', 'name': 'One'},
|
||||
\ {'root': '~/w2', 'name': 'Two', 'toc_header_level': 3},
|
||||
\ ]
|
||||
let s:cfg4 = nuwiki#lsp#settings()
|
||||
let s:g0 = s:cfg4.wikis[0]
|
||||
let s:g1 = s:cfg4.wikis[1]
|
||||
call s:check('global folds into wiki 0', get(s:g0, 'toc_header_level', 0) == 2)
|
||||
call s:check('global numbering folds', get(s:g0, 'html_header_numbering', 0) == 2)
|
||||
call s:check('per-wiki override wins', get(s:g1, 'toc_header_level', 0) == 3)
|
||||
call s:check('global still folds elsewhere', get(s:g1, 'html_header_numbering', 0) == 2)
|
||||
" The user's original dict must not be mutated by the fold.
|
||||
call s:check('no mutation of g:nuwiki_wikis', !has_key(g:nuwiki_wikis[0], 'toc_header_level'))
|
||||
|
||||
call writefile(s:out, $NUWIKI_VWC_OUT)
|
||||
@@ -1,192 +0,0 @@
|
||||
# vimwiki → nuwiki Parity Gaps
|
||||
|
||||
Tracking document for closing the gap between nuwiki and the original
|
||||
[vimwiki](https://github.com/vimwiki/vimwiki) plugin across **configuration**,
|
||||
**commands**, and **key mappings**.
|
||||
|
||||
Source of truth for upstream: vimwiki `master` —
|
||||
`autoload/vimwiki/vars.vim` (option defaults), `plugin/vimwiki.vim` +
|
||||
`ftplugin/vimwiki.vim` (commands/mappings), `doc/vimwiki.txt` (prose).
|
||||
|
||||
Audited 2026-05-31. Check items off as they land. Each row cites the nuwiki
|
||||
fix site.
|
||||
|
||||
---
|
||||
|
||||
## P1 — Core workflow gaps (fix first)
|
||||
|
||||
- [x] **Vim client split/tab link-follow commands** — `VimwikiSplitLink`,
|
||||
`VimwikiVSplitLink`, `VimwikiGoBackLink`, `VimwikiTabnewLink`,
|
||||
`VimwikiTabDropLink` exist only in the Neovim branch. Vim users lose
|
||||
split-window link following and back-navigation.
|
||||
_Fix:_ `ftplugin/vimwiki.vim` (Vim branch) — commands added + `:Nuwiki*`
|
||||
aliases; `follow_link_drop()` helper in `autoload/nuwiki/commands.vim`;
|
||||
true `:tab drop` tab-reuse wired in both clients (`lua/nuwiki/commands.lua`
|
||||
`open_uri` `'tabdrop'` case); `<C-S-CR>` repointed to tab-drop.
|
||||
- [x] **`gl<symbol>` change-symbol mappings** — upstream's one-key bullet/number
|
||||
symbol change (`gl*` `gl#` `gl-` `gl1` `gla` `gli` … plus `gL…`) has no
|
||||
nuwiki keymap; only `:…ChangeSymbol*` commands exist.
|
||||
_Fix:_ added normal-mode `gl{-,*,#,1,i,I,a,A}` (item) + `gL…` (whole list) in
|
||||
`lua/nuwiki/keymaps.lua` lists group and `ftplugin/vimwiki.vim` Vim-branch
|
||||
lists block, wired to the existing `list_change_symbol`. `1)` stays
|
||||
command-only (upstream shadows it with `1.`); normal-mode only (server acts on
|
||||
cursor item / whole list, not a visual range).
|
||||
- [x] **`gl<Space>` / `gL<Space>` semantics diverge** — upstream = *remove
|
||||
checkbox* from item / list siblings; nuwiki rebound to *remove done items*.
|
||||
Same keys, different effect (parity trap).
|
||||
_Fix:_ bound **bare `gl`/`gL`** to remove-checkbox-item / -in-list for exact
|
||||
upstream parity (`lua/nuwiki/keymaps.lua` lists block, `ftplugin/vimwiki.vim`
|
||||
Vim-branch lists block); they share the `gl…` prefix so they fire after
|
||||
`timeoutlen`, just like upstream. Remove-*done* is now command-only:
|
||||
`:NuwikiRemoveDone` (current list) gained a `-bang` so `:NuwikiRemoveDone!`
|
||||
reaches the whole-buffer sweep that `gL<Space>` previously held (the only
|
||||
prior entry point). Normal-mode only.
|
||||
|
||||
## Regressions (reported by users — verify, then fix)
|
||||
|
||||
- [x] **`<CR>` link-follow: wrong window placement + no create-on-follow on the
|
||||
Vim clients** — two related Vim-branch bugs (Neovim path was already correct):
|
||||
(a) on coc.nvim, `<CR>` opened the target in a **new tab** instead of the
|
||||
current window; (b) on **both** vim-lsp and coc, `<CR>` on a link to a
|
||||
not-yet-created page **failed to open/create it**.
|
||||
_Root cause:_ the Vim follow path delegated to the LSP client's jump UI
|
||||
(`:LspDefinition` / `CocActionAsync('jumpDefinition')`). Those fetch the
|
||||
target's text before jumping — vim-lsp `readfile()`s it for the quickfix entry
|
||||
(`autoload/lsp/utils/location.vim`), which throws `E484` for a missing file
|
||||
and aborts the jump (→ no create-on-follow); coc, lacking an explicit open
|
||||
command, fell back to the user's `coc.preferences.jumpCommand` (→ new tab).
|
||||
The server itself was correct throughout — verified over JSON-RPC that
|
||||
`textDocument/definition` returns a *synthesised* location for missing pages
|
||||
and resolves existing links.
|
||||
_Fix:_ stop delegating. `nuwiki#commands#follow_link_or_create()` /
|
||||
`follow_link_drop()` now resolve `textDocument/definition` directly and open
|
||||
the URI ourselves via a shared `s:open_definition(open_cmd)` helper
|
||||
(`autoload/nuwiki/commands.vim`) — `:edit` for `<CR>`, `tab drop` for
|
||||
`<C-S-CR>`. `:edit` opens a buffer for a missing path (save creates it,
|
||||
matching vimwiki) and gives exact current-window placement regardless of the
|
||||
user's coc jumpCommand. Covered by
|
||||
`cr.follow_opens_missing_page_in_current_window` in
|
||||
`development/tests/test-keymaps-vim.vim`; manually reproducible via
|
||||
`development/start-vim.sh` (vim-lsp) and `development/start-vim-coc.sh` (coc).
|
||||
- [x] **coc.nvim reported valid links as broken / couldn't resolve** — only via
|
||||
the new `development/start-vim-coc.sh` launcher: its generated
|
||||
`coc-settings.json` registered the server with `command`/`filetypes` but no
|
||||
`initializationOptions`, so `wiki_root` never reached the server and it
|
||||
resolved links against its default (`~/vimwiki`). _Fix:_ the launcher now
|
||||
emits `initializationOptions` **and** `settings.nuwiki` mirroring the vim-lsp
|
||||
client (`autoload/nuwiki/lsp.vim` `s:settings()`). Verified with a headless
|
||||
coc.nvim run: `[[Notes]]` resolves, only genuinely-missing links warn. (Shipped
|
||||
plugin only — no production code change; coc users configure their own
|
||||
`coc-settings.json` per the README snippet.)
|
||||
|
||||
## P2 — Moderate (real users hit these)
|
||||
|
||||
- [ ] **`<Leader>w<Leader>t` collision** — upstream = today-in-new-tab; nuwiki =
|
||||
tomorrow's diary. _Fix:_ `lua/nuwiki/keymaps.lua`, `ftplugin/vimwiki.vim`.
|
||||
- [ ] **Generated-section captions hardcoded** — `toc_header` /
|
||||
`toc_header_level`, `links_header`, `tags_header` (+levels).
|
||||
_Fix:_ `crates/nuwiki-lsp/src/config.rs` + server TOC/generate renderers.
|
||||
- [ ] **On-save autoregen family** — `auto_diary_index`, `auto_generate_links`,
|
||||
`auto_generate_tags`, `auto_tags` (only `auto_toc` / `auto_export` exist).
|
||||
_Fix:_ `config.rs` `RawWiki`/`WikiConfig` + the `didSave` path.
|
||||
- [ ] **`listsym_rejected`** — rejected glyph `-` is hardcoded.
|
||||
_Fix:_ `config.rs` `WikiConfig`.
|
||||
- [ ] **`custom_wiki2html` (+`_args`)** and **`base_url`** — external HTML
|
||||
converter hook + export URL prefix. _Fix:_ `config.rs` `HtmlConfig`.
|
||||
- [ ] **`map_prefix`** — `<Leader>w` hardcoded across the map layer.
|
||||
_Fix:_ `plugin/nuwiki.vim`, `lua/nuwiki/keymaps.lua`, `ftplugin/vimwiki.vim`.
|
||||
- [ ] **`<M-CR>` badd-link** — missing (badd is mouse-only via `<MiddleMouse>` /
|
||||
`:VimwikiBaddLink`). _Fix:_ `lua/nuwiki/keymaps.lua`, `ftplugin/vimwiki.vim`.
|
||||
- [ ] **`diary_caption_level` default divergence** — nuwiki `1` vs vimwiki `0`.
|
||||
_Fix:_ `config.rs:291` (and defaults in `lua/nuwiki/config.lua`).
|
||||
- [ ] **`diary_start_week_day`** — **wrongly removed** in `c63ec67`. The removal
|
||||
treated it as a server concern and hardwired the weekly diary to ISO-Monday
|
||||
(`crates/nuwiki-core/src/date.rs` `monday_of_iso_week`), but choosing which
|
||||
weekday the diary week begins on is a **client-side concern** and it **matters
|
||||
for parity** — non-Monday users lose the upstream option entirely. _Fix:_
|
||||
restore the config key client-side (`lua/nuwiki/config.lua`) and thread the
|
||||
week-start through to the diary date logic instead of hardcoding Monday.
|
||||
|
||||
## P3 — Niche / low impact
|
||||
|
||||
### Commands
|
||||
- [ ] `VimwikiVar` — config-var get/set introspection. _Fix:_ `plugin/nuwiki.vim`.
|
||||
- [ ] `VimwikiShowVersion` — print version (trivial). _Fix:_ `plugin/nuwiki.vim`.
|
||||
- [ ] `VimwikiReturn` — no Ex-command (mapping-driven only). _Fix:_ `ftplugin/vimwiki.vim`.
|
||||
- [ ] `VimwikiTableAlignQ` vs `AlignW` collapsed to one `table_align` — `gqq`
|
||||
(align) vs `gww` (align w/o resize) distinction lost.
|
||||
- [ ] `VimwikiTable` is `-nargs=1` vs upstream `-nargs=*` (can't pass cols+rows).
|
||||
- [ ] `VimwikiListChangeLvl` is `-nargs=?` vs upstream `-range -nargs=+`.
|
||||
- [ ] `VimwikiRebuildTags` lacks `-bang` ("rebuild all" variant).
|
||||
- [ ] `:VimwikiSearch` / `VWS` uses `lvimgrep`, not vimwiki's search engine.
|
||||
- [ ] `VimwikiIndex` family is buffer-local in nuwiki (upstream defines globally
|
||||
in `plugin/`); only `:…UISelect` is a global entry point.
|
||||
- [ ] **`VimwikiColorize` / `NuwikiColorize` drop their argument in the Neovim
|
||||
branch** — both are `-nargs=1`, but the Neovim defs call `colorize()` with no
|
||||
`<q-args>` (`ftplugin/vimwiki.vim:445,517`) while the Vim branch passes it
|
||||
(`:106,177`). The color name is silently ignored under Neovim (real bug, not
|
||||
just parity). _Fix:_ `ftplugin/vimwiki.vim` Neovim branch.
|
||||
- [ ] **`VimwikiTableMoveColumn{Left,Right}` dispatch to different backing names
|
||||
per client** — Vim branch → `table_move_left/right`; Neovim branch →
|
||||
`table_move_column_left/right`. Harmless today (each name exists in its own
|
||||
impl) but a divergence to converge.
|
||||
- [ ] **`NuwikiTabIndex` lacks `-count` in the Neovim branch** (`:452`) while its
|
||||
siblings (`VimwikiTabIndex`, `NuwikiIndex`, and the Vim-branch `NuwikiTabIndex`)
|
||||
all carry it — `vim.v.count` is still read, so a count is honored inconsistently.
|
||||
- [ ] **`VimwikiToggleListItem` / `Increment` / `DecrementListItem` lack
|
||||
`-range`** (both branches) vs upstream's `-range` (`:350`) — no visual-range
|
||||
checkbox toggle / symbol cycle.
|
||||
|
||||
### Config
|
||||
- [ ] `auto_header` — auto H1-from-filename on new page (server-side).
|
||||
- [ ] `create_link` — toggle to suppress link-target creation.
|
||||
- [ ] `dir_link` — index file to open when following a directory link.
|
||||
- [ ] `auto_chdir` — `:cd` into wiki root on open (client-side).
|
||||
- [ ] `bullet_types` / `cycle_bullets` — custom bullet glyphs + wrap-around.
|
||||
- [ ] `commentstring` (`'%%%s'`) — comment toggling/text-objects.
|
||||
- [ ] `color_tag_template` — `color_dic` present; template regex missing.
|
||||
- [ ] `rss_max_items` / `rss_name` — `:Rss` output hardcoded. _Fix:_ `HtmlConfig`.
|
||||
- [ ] `emoji_enable` — emoji substitution.
|
||||
- [ ] `html_header_numbering` (+`_sym`) — numbered HTML headers.
|
||||
- [ ] `valid_html_tags` — allowed inline HTML tags in export.
|
||||
- [ ] `generated_links_caption`, `toc_link_format`, `markdown_link_ext`.
|
||||
- [ ] `list_ignore_newline` / `text_ignore_newline` — export newline handling.
|
||||
|
||||
### Mappings
|
||||
- [ ] Visual `<CR>` normalize-link (visual `+` covers the same intent).
|
||||
- [ ] Insert `<S-CR>` multiline list item.
|
||||
- [ ] `gLH` / `gLL` / `gLR` — upstream binds these as case-variant **aliases**
|
||||
of `gLh` / `gLl` / `gLr` (same actions: dedent / indent whole item, renumber
|
||||
all lists; upstream `ftplugin/vimwiki.vim:553,555,561`). nuwiki has the
|
||||
lowercase forms but not the uppercase-suffix aliases. Cosmetic — same effect,
|
||||
redundant keys.
|
||||
|
||||
---
|
||||
|
||||
## Intentional divergences (no action — architectural)
|
||||
|
||||
These upstream options have no nuwiki equivalent by design, because nuwiki is
|
||||
an LSP-backed reimplementation rather than a pure-VimL plugin. Listed so future
|
||||
audits don't re-flag them.
|
||||
|
||||
- `nested_syntaxes` / `automatic_nested_syntaxes` — code-fence languages are
|
||||
auto-detected from the fence tag (`syntax/vimwiki.vim`); always on, no toggle.
|
||||
- `maxhi` — existence-based link highlighting; superseded by LSP diagnostics.
|
||||
- `conceal*` (`conceallevel`, `conceal_onechar_markers`, …) — semantic tokens
|
||||
replace conceal.
|
||||
- `ext2syntax` — syntax chosen by extension / `syntax` key automatically.
|
||||
- `folding` (upstream string) — nuwiki uses `folding = 'lsp' | 'expr' | 'off'`
|
||||
backed by LSP foldingRange.
|
||||
- `key_mappings` (dict) — replaced by Lua `mappings.<group>` + the
|
||||
`g:nuwiki_no_<group>_mappings` globals.
|
||||
- `CJK_length`, `listing_hl*`, `schemes_*`, `w32_dir_enc`, `menu`,
|
||||
`rx_todo` / `tag_format` — syntax internals, menu, or Vim/Win shims.
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
- Audit confidence: upstream defaults pulled from vimwiki `master`; a couple of
|
||||
values (`CJK_length`, `links_space_char`) were summarized loosely but don't
|
||||
affect the gap list.
|
||||
- nuwiki adds some commands with no upstream equivalent (e.g.
|
||||
`:NuwikiFindOrphans`) — additive, not divergences.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user