6 Commits

Author SHA1 Message Date
gffranco f6a543320e fix: use artifact v3 for GHES compatibility
Gitea (GHES) does not support upload-artifact@v4+.
Revert to v3 which is supported on GHES.
2026-06-03 22:16:40 -03:00
gffranco 678d62ac14 fix: use stable asset names without version in release workflow
CI / cargo fmt --check (pull_request) Successful in 31s
CI / cargo clippy (pull_request) Successful in 25s
CI / cargo test (pull_request) Successful in 32s
CI / editor keymaps (pull_request) Successful in 1m35s
Release / build aarch64-unknown-linux-musl (push) Failing after 1m2s
Release / build x86_64-unknown-linux-gnu (push) Failing after 1m1s
Release / build aarch64-unknown-linux-gnu (push) Failing after 1m9s
Release / build x86_64-unknown-linux-musl (push) Failing after 1m3s
Release / gitea release (push) Has been skipped
Rename release assets to remove the version so that
/releases/latest/download/nuwiki-ls-{target}.tar.gz always resolves
correctly from the install scripts.
2026-06-03 22:02:22 -03:00
gffranco d93637bb48 release: fix restore-keys pipe syntax for Gitea 2026-06-03 22:02:22 -03:00
gffranco 01b936fffc ci: fix restore-keys to use pipe syntax (Gitea doesn't support YAML list) 2026-06-03 22:02:22 -03:00
gffranco 5d3ce2fe0b ci: remove concurrency key (not supported by Gitea Actions) 2026-06-03 22:02:22 -03:00
gffranco d188b09fde ci: improve workflows with stricter checks, caching, and idempotency
CI improvements:
- Add concurrency group to cancel redundant runs on same branch
- Add cargo cache to fmt job (was missing entirely)
- Add timeout-minutes: 5 to fast jobs (fmt, clippy, test) for fail-fast
- Change RUST_BACKTRACE from 'short' to '1' for better CI debuggability
- Pin push trigger to branches: [main] to avoid double-firing on PR merges
- Test minimum supported Neovim (0.11.0) instead of latest patch (0.11.3)

Release improvements:
- Enforce RUSTFLAGS: -D warnings at env level and in matrix rustflags
- Remove redundant checkout in release job (only needs env vars)
- Add release notes generation from git log since previous tag
- Make release creation idempotent (check if release exists before creating)
- Fix release_id not being set when release already exists
2026-06-03 22:02:22 -03:00
97 changed files with 25826 additions and 1214 deletions
+81 -27
View File
@@ -6,63 +6,117 @@ on:
pull_request: pull_request:
branches: [main] branches: [main]
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: "1"
RUSTFLAGS: -D warnings
jobs: jobs:
editor-tests: fmt:
name: editor tests name: cargo fmt --check
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 15 timeout-minutes: 5
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@1.83
with:
components: rustfmt
- uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-fmt-${{ hashFiles('**/Cargo.lock', '**/Cargo.toml') }}
restore-keys: |
${{ runner.os }}-cargo-fmt-
- run: cargo fmt --all -- --check
clippy:
name: cargo clippy
runs-on: ubuntu-latest
timeout-minutes: 5
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
timeout-minutes: 5
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 + Vim - name: install Neovim + Vim
run: | run: |
set -euo pipefail set -euo pipefail
# Test the minimum supported Neovim version (0.11.0) so we # Test the minimum supported Neovim version (0.11.0) so we
# catch API regressions at the bottom of the supported range. # catch API regressions at the bottom of the supported range.
# Pinning to a specific patch version; bump when 0.12 ships.
NVIM_VER=v0.11.0 NVIM_VER=v0.11.0
curl -fsSL -o /tmp/nvim.tar.gz \ curl -fsSL -o /tmp/nvim.tar.gz \
"https://github.com/neovim/neovim/releases/download/${NVIM_VER}/nvim-linux-x86_64.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 tar -xzf /tmp/nvim.tar.gz -C /opt
sudo ln -sf /opt/nvim-linux-x86_64/bin/nvim /usr/local/bin/nvim sudo ln -sf /opt/nvim-linux-x86_64/bin/nvim /usr/local/bin/nvim
nvim --version | head -1 nvim --version | head -1
# Vim from apt is fine — only the pure-VimL keymaps run there.
sudo apt-get update -qq sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends vim sudo apt-get install -y --no-install-recommends vim
vim --version | head -1 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 - name: run Neovim keymap harness
run: ./development/tests/test-keymaps.sh run: ./development/tests/test-keymaps.sh
- name: run Vim keymap harness - name: run Vim keymap harness
run: ./development/tests/test-keymaps-vim.sh run: ./development/tests/test-keymaps-vim.sh
- name: run Neovim config parity harness - name: run Neovim config parity harness
run: ./development/tests/test-config.sh run: ./development/tests/test-config.sh
- name: run Vim config parity harness - name: run Vim config parity harness
run: ./development/tests/test-config-vim.sh run: ./development/tests/test-config-vim.sh
- name: run Neovim calendar integration harness - name: run Neovim calendar integration harness
run: ./development/tests/test-calendar.sh run: ./development/tests/test-calendar.sh
- name: run Vim calendar integration harness - name: run Vim calendar integration harness
run: ./development/tests/test-calendar-vim.sh run: ./development/tests/test-calendar-vim.sh
- name: run Vim vars-shim harness - name: run Vim vars-shim harness
run: ./development/tests/test-vars-vim.sh run: ./development/tests/test-vars-vim.sh
- name: run Vim vimwiki-config compat harness
run: ./development/tests/test-vimwiki-compat-vim.sh
- name: run Neovim global-shorthand harness
run: ./development/tests/test-global-shorthand.sh
- name: run Vim coc-registration harness
run: ./development/tests/test-coc-register-vim.sh
+187
View File
@@ -0,0 +1,187 @@
name: Release
on:
push:
tags:
- 'v*'
permissions:
contents: write
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: short
RUSTFLAGS: -D warnings
jobs:
build:
name: build ${{ matrix.target }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- target: x86_64-unknown-linux-gnu
apt: ""
rustflags: "-D warnings"
- 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: "-D warnings"
- target: x86_64-unknown-linux-musl
apt: "musl-tools"
rustflags: "-D warnings"
- target: aarch64-unknown-linux-musl
apt: ""
rustflags: "-D warnings -C linker=rust-lld -C link-self-contained=yes"
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@1.83
with:
targets: ${{ matrix.target }}
- 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
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
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
# Use a stable name without version so /releases/latest/download/nuwiki-ls-{target}.tar.gz always resolves.
stable="nuwiki-ls-${{ matrix.target }}.tar.gz"
mv "$archive" "$stable"
echo "archive=$stable" >> "$GITHUB_OUTPUT"
- name: Upload build artifact
uses: actions/upload-artifact@v3
with:
name: nuwiki-ls-${{ matrix.target }}
path: ${{ steps.package.outputs.archive }}
if-no-files-found: error
release:
name: gitea release
needs: build
runs-on: ubuntu-latest
steps:
- name: Download all build artifacts
# download-artifact@v3 nests each artifact under its own dir
# (artifacts/<artifact-name>/<file>), so recurse.
uses: actions/download-artifact@v3
with:
path: ./artifacts
- 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
- name: Generate release notes
id: notes
run: |
set -euo pipefail
# Get commits since the previous tag (or the first commit if this is the first release).
prev_tag=$(git tag --sort=-version:refname | awk "NR==2" || echo "")
if [ -n "$prev_tag" ]; then
log=$(git log --oneline "$prev_tag"..HEAD)
else
log=$(git log --oneline --max-parents=0..HEAD)
fi
# Write notes to a file for later use.
cat > release-notes.txt <<EOF
**Full Changelog**: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/compare/${prev_tag:-$(git rev-list --max-parents=0 HEAD)}...${GITHUB_REF_NAME}
**Changes**:
$log
EOF
echo "Generated release notes"
- name: Create Gitea release + upload assets
env:
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
GITEA_SERVER: ${{ github.server_url }}
REPO: ${{ github.repository }}
TAG: ${{ github.ref_name }}
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"
# Check if a release for this tag already exists (idempotency).
release_id=""
existing=$(curl --fail --silent --show-error -o /dev/null -w "%{http_code}" \
-H "Authorization: token $RELEASE_TOKEN" \
"$GITEA_SERVER/api/v1/repos/$REPO/releases/tags/$TAG" \
|| true)
if [ "$existing" = "200" ]; then
echo "Release for $TAG already exists — fetching existing id"
release_id=$(curl --fail --silent --show-error \
-H "Authorization: token $RELEASE_TOKEN" \
"$GITEA_SERVER/api/v1/repos/$REPO/releases/tags/$TAG" \
| jq -r '.id')
else
payload=$(jq -n --arg tag "$TAG" --arg name "$TAG" \
'{tag_name: $tag, name: $name, draft: false, prerelease: false}')
release_id=$(curl --fail --silent --show-error \
-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
fi
echo "Created release id=$release_id"
fi
# 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."
+2 -3
View File
@@ -1,10 +1,9 @@
# Rust build artifacts # Rust build artifacts
/target /target
**/*.rs.bk
# Plugin runtime: downloaded LSP binary lives here (see development/SPEC.md) # Plugin runtime: downloaded LSP binary lives here (see SPEC.md §7.2)
/bin /bin
# nuwiki-rs is cloned here during install fallback, not tracked
nuwiki-rs/
# Editor / OS noise # Editor / OS noise
.DS_Store .DS_Store
Generated
+835
View File
@@ -0,0 +1,835 @@
# 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
View File
@@ -0,0 +1,22 @@
[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 }
+19 -67
View File
@@ -6,16 +6,7 @@ nuwiki replaces the original [vimwiki](https://github.com/vimwiki/vimwiki)
plugin while keeping its file format, keymaps, and `:Vimwiki*` command plugin while keeping its file format, keymaps, and `:Vimwiki*` command
surface intact. The substantive work happens in a Rust LSP daemon — Vim surface intact. The substantive work happens in a Rust LSP daemon — Vim
and Neovim are thin client layers that wire up keystrokes and display and Neovim are thin client layers that wire up keystrokes and display
results. The `nuwiki-ls` binary is distributed separately in the results.
[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 ## Features
@@ -81,12 +72,9 @@ results. The `nuwiki-ls` binary is distributed separately in the
The plugin ships a `nuwiki-ls` binary that the editor talks to over LSP 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 stdio. The `install()` helper downloads a pre-built release for your
platform from the [nuwiki-rs](https://code.gfran.co/gffranco/nuwiki-rs) platform, falling back to `cargo build --release` if no asset matches.
repository. If the download fails (or `curl`/`tar` are unavailable), it You can run it any time with `:NuwikiInstall` (works in both Vim and
falls back to cloning nuwiki-rs and building with `cargo` — which requires Neovim) instead of relying on a plugin-manager build hook.
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 ### Requirements
@@ -94,14 +82,12 @@ plugin-manager build hook.
Older releases fall back to a `vim.lsp.start` autocmd but aren't Older releases fall back to a `vim.lsp.start` autocmd but aren't
officially supported. officially supported.
- **Vim 9+** — needs a third-party LSP client: - **Vim 9+** — needs a third-party LSP client:
[`vim-lsp`](https://github.com/prabirshrestha/vim-lsp) or [`vim-lsp`](https://github.com/prabirshrestha/vim-lsp) (recommended,
[`coc.nvim`](https://github.com/neoclide/coc.nvim) — both auto-registered) or [`coc.nvim`](https://github.com/neoclide/coc.nvim)
auto-registered, no manual config (see [Vim LSP client (one-time `coc-settings.json` entry — see [Vim LSP client
setup](#vim-lsp-client-setup)). setup](#vim-lsp-client-setup)).
- **curl + tar** — for downloading pre-built release binaries. - **Rust toolchain (1.83+ stable)** — only needed when building the
- **Rust toolchain (1.83+ stable)** — only needed when building binary from source; pre-built release downloads skip this.
the binary from source as a fallback (when no pre-built asset matches
your platform or curl/tar are unavailable).
### lazy.nvim ### lazy.nvim
@@ -140,13 +126,10 @@ call dein#add('gffranco/nuwiki', {
```sh ```sh
git clone https://code.gfran.co/gffranco/nuwiki ~/.vim/pack/gffranco/start/nuwiki git clone https://code.gfran.co/gffranco/nuwiki ~/.vim/pack/gffranco/start/nuwiki
cd ~/.vim/pack/gffranco/start/nuwiki && vim -e -s -c "source scripts/download_bin.vim" -c "q" cd ~/.vim/pack/gffranco/start/nuwiki && cargo build --release -p nuwiki-ls
mkdir -p bin && cp target/release/nuwiki-ls bin/
``` ```
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 ### Vim LSP client setup
Neovim 0.11+ registers the server through its built-in LSP client Neovim 0.11+ registers the server through its built-in LSP client
@@ -162,18 +145,10 @@ auto-enables it. Your wiki settings (`g:nuwiki_wiki_root`,
`g:nuwiki_wikis`, …) are forwarded as the server's initialization `g:nuwiki_wikis`, …) are forwarded as the server's initialization
options. options.
**coc.nvim**also zero configuration. nuwiki registers itself with **coc.nvim**[`coc.nvim`](https://github.com/neoclide/coc.nvim) reads
[`coc.nvim`](https://github.com/neoclide/coc.nvim) programmatically its server list from `coc-settings.json`, which the plugin can't edit
(`coc#config('languageserver.nuwiki', …)`) when the first `.wiki` buffer on your behalf, so add the entry once yourself (open it with
loads, forwarding the same wiki settings (`g:nuwiki_wiki_root`, `:CocConfig`):
`g:nuwiki_wikis`, your `g:vimwiki_*` config, the global shorthand, …) as
the server's initialization options. **No `coc-settings.json`
`languageserver` entry needed** — change your wiki config and reload; the
new settings are picked up.
To manage the entry yourself instead, set `let g:nuwiki_no_coc_register = 1`
and add it to `coc-settings.json` manually (nuwiki then just prints the
snippet on first load):
```json ```json
{ {
@@ -213,13 +188,9 @@ aliases.
./development/start-vim.sh # same, for plain Vim (clones vim-lsp on first run) ./development/start-vim.sh # same, for plain Vim (clones vim-lsp on first run)
``` ```
Both scripts download the LSP binary, seed a scratch wiki, and launch the Both scripts build the LSP binary, seed a scratch wiki, and launch the
editor against an isolated state dir under `$XDG_CACHE_HOME/nuwiki-dev`. 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 ## Configuration
@@ -366,14 +337,6 @@ The `<Leader>w` prefix in the `wiki_prefix`, `links`, and `html_export` rows is
#### Per-wiki keys (`wikis[]`) #### Per-wiki keys (`wikis[]`)
Most display/generation keys below can also be set **once at the top level**
as a default for every wiki (vimwiki-style global shorthand) — via `setup({ … })`
or `g:nuwiki_<key>` — and a per-wiki value overrides it. The keys that honour
this are `toc_header`, `toc_header_level`, `toc_link_format`, `links_header`,
`links_header_level`, `tags_header`, `tags_header_level`,
`html_header_numbering`, `html_header_numbering_sym`, `links_space_char`,
`list_margin`, `listsyms`, `listsym_rejected`, and the `auto_*` toggles.
| Key | Type | Default | Accepted values | | Key | Type | Default | Accepted values |
|-----|------|---------|-----------------| |-----|------|---------|-----------------|
| `name` | string | — | any label | | `name` | string | — | any label |
@@ -462,6 +425,7 @@ For users configuring without Lua.
| `g:nuwiki_no_folding` | `0` | `1` skips foldexpr setup | | `g:nuwiki_no_folding` | `0` | `1` skips foldexpr setup |
| `g:nuwiki_mouse_mappings` | `0` | `1` enables mouse maps | | `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_binary_path` | _(auto)_ | path to a prebuilt `nuwiki-ls` binary, bypassing the bundled one |
| `g:nuwiki_build_from_source` | `0` | `1` builds the binary with `cargo` instead of downloading a release |
| `g:nuwiki_no_calendar` | `0` | `1` opts out of calendar-vim integration | | `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_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 | | `g:nuwiki_auto_header` | `0` | `1` insert a level-1 header from the filename on new wiki pages |
@@ -696,20 +660,8 @@ match vimwiki. To migrate:
1. Drop the original `vimwiki` plugin from your config. 1. Drop the original `vimwiki` plugin from your config.
2. Install nuwiki. 2. Install nuwiki.
3. Keep your existing config, or point `wiki_root` / `wikis[i].root` at 3. Point `wiki_root` (or each `wikis[i].root`) at your existing
your directory. directory.
**Existing `g:vimwiki_list` config works as-is (Vim & Neovim).** When you
haven't configured nuwiki natively (`g:nuwiki_wikis` / `setup()` / `g:nuwiki_*`),
both clients read your upstream `g:vimwiki_list` and `g:vimwiki_*` globals and
translate them: each wiki's `path`/`path_html`/`template_*`/`auto_export`
plus globals like `toc_header_level`, `html_header_numbering`,
`html_header_numbering_sym`, `links_space_char`, and `list_margin`.
Native config always takes precedence. (Settings nuwiki implements
differently — see [`known-issues.md`](./known-issues.md) — are ignored.)
On Neovim with lazy.nvim, swap the `vimwiki` plugin spec for nuwiki and
call `require('nuwiki').setup()` — your `vim.g.vimwiki_list` is picked up
unchanged.
Existing pages, diary entries, tags, and templates work without Existing pages, diary entries, tags, and templates work without
modification. The first workspace scan after launch may show an empty modification. The first workspace scan after launch may show an empty
+17 -23
View File
@@ -588,13 +588,14 @@ endfunction
" open their wiki from any buffer. Files are opened directly from config; " open their wiki from any buffer. Files are opened directly from config;
" the LSP auto-starts via the FileType autocmd once the buffer loads. " the LSP auto-starts via the FileType autocmd once the buffer loads.
" Return a dict with {root, ext, idx, diary_rel, diary_idx, space} for wiki #n " Return a dict with {root, ext, idx, diary_rel, diary_idx} for wiki #n
" (0-based). Resolves through nuwiki#config#wikis() — so g:nuwiki_wikis AND an " (0-based). Prefers g:nuwiki_wikis[n]; falls back to scalar g:nuwiki_*
" upstream g:vimwiki_list both work — and falls back to the scalar g:nuwiki_* " vars and finally to built-in defaults.
" vars (single-wiki shorthand) and finally built-in defaults.
function! s:wiki_cfg(n) abort function! s:wiki_cfg(n) abort
let l:wikis = nuwiki#config#wikis() let l:w = {}
let l:w = (a:n >= 0 && a:n < len(l:wikis)) ? l:wikis[a:n] : {} if exists('g:nuwiki_wikis') && len(g:nuwiki_wikis) > a:n
let l:w = g:nuwiki_wikis[a:n]
endif
let l:root = expand(get(l:w, 'root', let l:root = expand(get(l:w, 'root',
\ get(g:, 'nuwiki_wiki_root', '~/vimwiki'))) \ get(g:, 'nuwiki_wiki_root', '~/vimwiki')))
let l:ext = get(l:w, 'file_extension', let l:ext = get(l:w, 'file_extension',
@@ -611,15 +612,14 @@ function! s:wiki_cfg(n) abort
endfunction endfunction
" Build the list of configured wikis as {name, root, ext, idx, diary_rel, " Build the list of configured wikis as {name, root, ext, idx, diary_rel,
" diary_idx, space} dicts, straight from config — no LSP round-trip. Drives the " diary_idx} dicts, straight from config — no LSP round-trip. Drives the wiki
" wiki picker so it works before any wiki buffer (and therefore the server) " picker so it works before any wiki buffer (and therefore the server) exists.
" exists. Falls back to a single entry from the scalar `g:nuwiki_wiki_root`. " Falls back to a single entry from the scalar `g:nuwiki_wiki_root` config.
function! nuwiki#commands#wiki_list() abort function! nuwiki#commands#wiki_list() abort
let l:wikis = nuwiki#config#wikis()
let l:list = [] let l:list = []
if !empty(l:wikis) if exists('g:nuwiki_wikis') && !empty(g:nuwiki_wikis)
let l:n = 0 let l:n = 0
for l:w in l:wikis for l:w in g:nuwiki_wikis
let l:c = s:wiki_cfg(l:n) let l:c = s:wiki_cfg(l:n)
let l:c['name'] = get(l:w, 'name', fnamemodify(l:c.root, ':t')) let l:c['name'] = get(l:w, 'name', fnamemodify(l:c.root, ':t'))
call add(l:list, l:c) call add(l:list, l:c)
@@ -725,23 +725,18 @@ endfunction
" ===== Generation + workspace ===== " ===== Generation + workspace =====
function! nuwiki#commands#toc_generate() abort function! nuwiki#commands#toc_generate() abort
" Send the cursor line (0-based) so a fresh TOC is inserted there. call s:exec('nuwiki.toc.generate', [{'uri': s:buf_uri()}])
call s:exec('nuwiki.toc.generate',
\ [{'uri': s:buf_uri(), 'line': line('.') - 1}])
endfunction endfunction
" `:VimwikiGenerateLinks [rel_path]` — with no arg, list every page; with a " `:VimwikiGenerateLinks [rel_path]` — with no arg, list every page; with a
" rel-path arg, scope the generated links to that subtree (server-side " rel-path arg, scope the generated links to that subtree (server-side
" `generateForPath`). Matches upstream's optional path argument. " `generateForPath`). Matches upstream's optional path argument.
function! nuwiki#commands#links_generate(...) abort function! nuwiki#commands#links_generate(...) abort
let l:path = a:0 >= 1 ? a:1 : '' let l:path = a:0 >= 1 ? a:1 : ''
" Cursor line (0-based) so a fresh section is inserted there.
let l:line = line('.') - 1
if l:path !=# '' if l:path !=# ''
call s:exec('nuwiki.links.generateForPath', call s:exec('nuwiki.links.generateForPath',
\ [{'uri': s:buf_uri(), 'path': l:path, 'line': l:line}]) \ [{'uri': s:buf_uri(), 'path': l:path}])
else else
call s:exec('nuwiki.links.generate', call s:exec('nuwiki.links.generate', [{'uri': s:buf_uri()}])
\ [{'uri': s:buf_uri(), 'line': l:line}])
endif endif
endfunction endfunction
function! nuwiki#commands#check_links(...) abort function! nuwiki#commands#check_links(...) abort
@@ -803,8 +798,7 @@ function! nuwiki#commands#tags_search(query) abort
endfunction endfunction
function! nuwiki#commands#tags_generate_links(tag) abort function! nuwiki#commands#tags_generate_links(tag) abort
" Cursor line (0-based) so a fresh section is inserted there. let l:args = { 'uri': s:buf_uri() }
let l:args = { 'uri': s:buf_uri(), 'line': line('.') - 1 }
if a:tag !=# '' if a:tag !=# ''
let l:args['tag'] = a:tag let l:args['tag'] = a:tag
endif endif
@@ -1175,7 +1169,7 @@ endfunction
" Mirrors `lua/nuwiki/commands.lua`'s `align_table_at` so plain Vim " Mirrors `lua/nuwiki/commands.lua`'s `align_table_at` so plain Vim
" buffers get the same "tighten columns as cells grow" behaviour the " buffers get the same "tighten columns as cells grow" behaviour the
" Neovim path enjoys, without needing a vim-lsp roundtrip. Algorithm " Neovim path enjoys, without needing a vim-lsp roundtrip. Algorithm
" matches `nuwiki-lsp/src/commands.rs::ops::render_aligned_table` (nuwiki-rs repo). " matches `crates/nuwiki-lsp/src/commands.rs::ops::render_aligned_table`.
function! s:is_table_row(line) abort function! s:is_table_row(line) abort
return a:line =~# '^\s*|.*|\s*$' return a:line =~# '^\s*|.*|\s*$'
-103
View File
@@ -1,103 +0,0 @@
" autoload/nuwiki/config.vim — single source of truth for the configured wiki
" list, shared by the LSP payload (lsp.vim) and the buffer-side commands
" (commands.vim, diary.vim, complete.vim).
"
" Resolves wikis from g:nuwiki_wikis (native) or, when that's unset, an upstream
" g:vimwiki_list (drop-in compatibility), translating upstream key names and
" folding the global scalar defaults (g:vimwiki_* / g:nuwiki_*) into each entry.
" Everything downstream — `<Leader>ww`, link resolution, the server payload —
" then sees the same wikis.
" Per-wiki dict keys: upstream `g:vimwiki_list[i]` key -> nuwiki/server key.
let s:vimwiki_wiki_map = {
\ 'path': 'root',
\ 'path_html': 'html_path',
\ 'template_path': 'template_path',
\ 'template_default': 'template_default',
\ 'template_ext': 'template_ext',
\ 'css_name': 'css_name',
\ 'auto_export': 'auto_export',
\ 'auto_toc': 'auto_toc',
\ 'syntax': 'syntax',
\ 'ext': 'file_extension',
\ 'index': 'index',
\ 'diary_rel_path': 'diary_rel_path',
\ 'diary_index': 'diary_index',
\ 'name': 'name',
\ }
" Global scalar settings vimwiki applies to every wiki (via wikilocal
" defaults): upstream suffix (`g:vimwiki_<suffix>`) -> nuwiki/server key.
let s:scalar_global_map = {
\ 'toc_header': 'toc_header',
\ 'toc_header_level': 'toc_header_level',
\ 'toc_link_format': 'toc_link_format',
\ 'links_header': 'links_header',
\ 'links_header_level': 'links_header_level',
\ 'tags_header': 'tags_header',
\ 'tags_header_level': 'tags_header_level',
\ 'html_header_numbering': 'html_header_numbering',
\ 'html_header_numbering_sym': 'html_header_numbering_sym',
\ 'links_space_char': 'links_space_char',
\ 'list_margin': 'list_margin',
\ 'listsyms': 'listsyms',
\ 'listsym_rejected': 'listsym_rejected',
\ 'auto_export': 'auto_export',
\ 'auto_toc': 'auto_toc',
\ 'auto_generate_links': 'auto_generate_links',
\ 'auto_generate_tags': 'auto_generate_tags',
\ 'auto_diary_index': 'auto_diary_index',
\ }
" Resolve the per-wiki scalar globals: vimwiki values first (compat), then
" nuwiki-native `g:nuwiki_<key>` overrides on top.
function! nuwiki#config#scalar_globals() abort
let l:g = {}
for [l:src, l:dst] in items(s:scalar_global_map)
if exists('g:vimwiki_' . l:src)
let l:g[l:dst] = get(g:, 'vimwiki_' . l:src)
endif
endfor
for l:dst in values(s:scalar_global_map)
if exists('g:nuwiki_' . l:dst)
let l:g[l:dst] = get(g:, 'nuwiki_' . l:dst)
endif
endfor
return l:g
endfunction
" Translate an upstream g:vimwiki_list into nuwiki's wiki list, folding the
" given global scalars into each entry. Returns [] when unset/malformed.
function! s:wikis_from_vimwiki(globals) abort
if !exists('g:vimwiki_list') || type(g:vimwiki_list) != type([]) || empty(g:vimwiki_list)
return []
endif
let l:wikis = []
for l:vw in g:vimwiki_list
if type(l:vw) != type({}) | continue | endif
let l:w = copy(a:globals)
for [l:src, l:dst] in items(s:vimwiki_wiki_map)
if has_key(l:vw, l:src)
let l:w[l:dst] = l:vw[l:src]
endif
endfor
if has_key(l:w, 'root')
call add(l:wikis, l:w)
endif
endfor
return l:wikis
endfunction
" The configured wikis as normalized dicts (nuwiki/server key names), with the
" global scalar defaults folded in (per-wiki value wins). Priority:
" 1. g:nuwiki_wikis (native)
" 2. g:vimwiki_list (upstream drop-in)
" Returns [] when neither is set — callers fall back to the single-wiki
" shorthand (g:nuwiki_wiki_root).
function! nuwiki#config#wikis() abort
let l:globals = nuwiki#config#scalar_globals()
if exists('g:nuwiki_wikis') && !empty(g:nuwiki_wikis)
return map(copy(g:nuwiki_wikis), {_, w -> extend(copy(l:globals), w, 'force')})
endif
return s:wikis_from_vimwiki(l:globals)
endfunction
+10 -36
View File
@@ -3,33 +3,6 @@
" ===== Calendar-Vim Integration ===== " ===== Calendar-Vim Integration =====
" The wiki index (0-based) of the most recently entered wiki buffer. The
" calendar shows / creates diary entries for this wiki, so opening the calendar
" while editing `ifood_wiki` diaries into ifood_wiki — not always the first
" wiki. Updated by the BufEnter autocmd in plugin/nuwiki.vim; defaults to 0.
" (The LSP-driven diary commands already follow the current buffer via its URI;
" this mirrors that for the VimL calendar-vim callbacks.)
let s:current_wiki_idx = 0
" Record the wiki that owns `file` as the current one. Called on entering a
" wiki buffer. No-op when the file is under no configured wiki.
function! nuwiki#diary#track_wiki(file) abort
let l:file = fnamemodify(a:file, ':p')
if empty(l:file)
return
endif
let l:n = 0
for l:w in nuwiki#commands#wiki_list()
let l:root = fnamemodify(expand(l:w.root), ':p')
if l:root[len(l:root) - 1:] !=# '/' | let l:root .= '/' | endif
if l:file[: len(l:root) - 1] ==# l:root
let s:current_wiki_idx = l:n
return
endif
let l:n += 1
endfor
endfunction
" Function: nuwiki#diary#calendar_action(day, month, year, week, dir) " Function: nuwiki#diary#calendar_action(day, month, year, week, dir)
" Called by calendar-vim when user presses Enter on a date. " Called by calendar-vim when user presses Enter on a date.
" day/month/year are numeric integers; dir is 'V' for a vertical split (any " day/month/year are numeric integers; dir is 'V' for a vertical split (any
@@ -39,8 +12,8 @@ function! nuwiki#diary#calendar_action(day, month, year, week, dir) abort
" Build YYYY-MM-DD from the parts passed by calendar-vim " Build YYYY-MM-DD from the parts passed by calendar-vim
let l:date_str = printf('%04d-%02d-%02d', a:year, a:month, a:day) let l:date_str = printf('%04d-%02d-%02d', a:year, a:month, a:day)
" Wiki of the buffer the calendar was opened from (falls back to the first). " Get wiki configuration for the first wiki (index 0)
let l:c = s:wiki_cfg(s:current_wiki_idx) let l:c = s:wiki_cfg(0)
" Construct file path: {root}/{diary_rel}/{YYYY-MM-DD}.{ext} " Construct file path: {root}/{diary_rel}/{YYYY-MM-DD}.{ext}
let l:file_path = l:c.root . '/' . l:c.diary_rel . '/' . l:date_str . l:c.ext let l:file_path = l:c.root . '/' . l:c.diary_rel . '/' . l:date_str . l:c.ext
@@ -89,8 +62,8 @@ function! nuwiki#diary#calendar_sign(day, month, year) abort
" Build YYYY-MM-DD from the parts passed by calendar-vim " Build YYYY-MM-DD from the parts passed by calendar-vim
let l:date_str = printf('%04d-%02d-%02d', a:year, a:month, a:day) let l:date_str = printf('%04d-%02d-%02d', a:year, a:month, a:day)
" Wiki of the buffer the calendar was opened from (falls back to the first). " Get wiki configuration for the first wiki (index 0)
let l:c = s:wiki_cfg(s:current_wiki_idx) let l:c = s:wiki_cfg(0)
" Construct expected file path " Construct expected file path
let l:file_path = l:c.root . '/' . l:c.diary_rel . '/' . l:date_str . l:c.ext let l:file_path = l:c.root . '/' . l:c.diary_rel . '/' . l:date_str . l:c.ext
@@ -102,12 +75,13 @@ function! nuwiki#diary#calendar_sign(day, month, year) abort
return '' return ''
endfunction endfunction
" Helper: get wiki configuration for wiki n (0-based). Resolves through " Helper: get wiki configuration for wiki n (0-based).
" nuwiki#config#wikis() so g:nuwiki_wikis AND an upstream g:vimwiki_list both " Mirrors the same logic used by nuwiki#commands#open_diary_path etc.
" work, falling back to the scalar g:nuwiki_* single-wiki shorthand.
function! s:wiki_cfg(n) abort function! s:wiki_cfg(n) abort
let l:wikis = nuwiki#config#wikis() let l:w = {}
let l:w = (a:n >= 0 && a:n < len(l:wikis)) ? l:wikis[a:n] : {} if exists('g:nuwiki_wikis') && len(g:nuwiki_wikis) > a:n
let l:w = g:nuwiki_wikis[a:n]
endif
let l:root = expand(get(l:w, 'root', let l:root = expand(get(l:w, 'root',
\ get(g:, 'nuwiki_wiki_root', '~/vimwiki'))) \ get(g:, 'nuwiki_wiki_root', '~/vimwiki')))
let l:ext = get(l:w, 'file_extension', let l:ext = get(l:w, 'file_extension',
+22 -71
View File
@@ -45,7 +45,22 @@ function! nuwiki#lsp#start() abort
endif endif
if exists(':CocConfig') == 2 || exists('*coc#config') if exists(':CocConfig') == 2 || exists('*coc#config')
call s:register_with_coc(l:bin) " coc.nvim — coc reads its server list from coc-settings.json.
" We can't inject it dynamically without owning the file, so just point
" the user at the snippet. `initializationOptions` carries wiki_root etc.
" to the server exactly like the vim-lsp registration above — without it
" the server resolves links against its default root, so existing pages
" look broken and follow-to-create lands in the wrong directory.
echohl WarningMsg
echom 'nuwiki: coc.nvim detected. Add this to your coc-settings.json:'
echom ' "languageserver": {'
echom ' "nuwiki": {'
echom ' "command": "' . l:bin . '",'
echom ' "filetypes": ["vimwiki"],'
echom ' "initializationOptions": ' . json_encode(s:settings())
echom ' }'
echom ' }'
echohl None
return return
endif endif
@@ -56,66 +71,6 @@ function! nuwiki#lsp#start() abort
echohl None echohl None
endfunction endfunction
" ===== coc.nvim integration =====
"
" Register the nuwiki language server with coc *programmatically* via
" coc#config, so users don't have to hand-maintain a coc-settings.json entry
" per wiki. The config (wiki roots, per-wiki keys, the vimwiki/nuwiki global
" shorthand) comes from s:settings() — the same payload the vim-lsp path sends.
" coc reacts to the `languageserver` config change, registers the server, and
" starts it for the open vimwiki buffer.
"
" Opt out with `let g:nuwiki_no_coc_register = 1` to manage the entry in
" coc-settings.json yourself (we then just print the snippet).
function! s:register_with_coc(bin) abort
if get(g:, 'nuwiki_no_coc_register', 0)
call s:print_coc_snippet(a:bin)
return
endif
let s:coc_bin = a:bin
if get(g:, 'coc_service_initialized', 0)
call s:coc_register()
else
" coc hasn't finished starting its node service yet — defer until it has,
" otherwise the updateConfig notification is sent to a service that isn't
" listening.
augroup nuwiki_coc_register
autocmd!
autocmd User CocNvimInit ++once call s:coc_register()
augroup END
endif
endfunction
function! s:coc_register() abort
let l:cfg = s:settings()
" Call coc#config directly — `exists('*coc#config')` can't be trusted because
" it doesn't trigger autoloading, so we let the call autoload coc.vim and
" fall back to the manual snippet only if coc genuinely isn't there.
try
call coc#config('languageserver.nuwiki', {
\ 'command': s:coc_bin,
\ 'filetypes': ['vimwiki'],
\ 'initializationOptions': l:cfg,
\ 'settings': { 'nuwiki': l:cfg },
\ })
catch /^Vim\%((\a\+)\)\=:E11[78]/
call s:print_coc_snippet(s:coc_bin)
endtry
endfunction
function! s:print_coc_snippet(bin) abort
echohl WarningMsg
echom 'nuwiki: coc.nvim detected. Add this to your coc-settings.json:'
echom ' "languageserver": {'
echom ' "nuwiki": {'
echom ' "command": "' . a:bin . '",'
echom ' "filetypes": ["vimwiki"],'
echom ' "initializationOptions": ' . json_encode(s:settings())
echom ' }'
echom ' }'
echohl None
endfunction
function! s:bin_path() abort function! s:bin_path() abort
if exists('g:nuwiki_binary_path') && filereadable(g:nuwiki_binary_path) if exists('g:nuwiki_binary_path') && filereadable(g:nuwiki_binary_path)
return g:nuwiki_binary_path return g:nuwiki_binary_path
@@ -134,16 +89,12 @@ function! s:settings() abort
\ 'link_severity': get(g:, 'nuwiki_link_severity', 'warn'), \ 'link_severity': get(g:, 'nuwiki_link_severity', 'warn'),
\ }, \ },
\ } \ }
" Wiki list comes from the shared resolver (nuwiki#config#wikis), so the " Include the per-wiki list when configured via g:nuwiki_wikis so the
" server payload and the buffer-side commands agree on which wikis exist — " server knows each wiki's root, diary path, etc. Without this it only
" native g:nuwiki_wikis or an upstream g:vimwiki_list, globals already folded. " sees wiki_root and resolves links relative to that directory instead
let l:wikis = nuwiki#config#wikis() " of the individual wiki's root.
if !empty(l:wikis) if exists('g:nuwiki_wikis') && !empty(g:nuwiki_wikis)
let l:cfg['wikis'] = l:wikis let l:cfg['wikis'] = g:nuwiki_wikis
else
" Single-wiki shorthand: fold the global scalars into the top-level payload
" so the server's single-wiki desugaring honours them.
call extend(l:cfg, nuwiki#config#scalar_globals(), 'force')
endif endif
return l:cfg return l:cfg
endfunction endfunction
+15
View File
@@ -0,0 +1,15 @@
[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]
+192
View File
@@ -0,0 +1,192 @@
//! 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,
}
+188
View File
@@ -0,0 +1,188 @@
//! 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),
/// A soft line break joining two content lines within a paragraph or
/// list item. Consumers treat it as whitespace; the HTML renderer emits
/// a space (vimwiki `*_ignore_newline = 1`, the default) or `<br />`
/// (`= 0`).
SoftBreak(SoftBreakNode),
}
impl InlineNode {
/// The source [`Span`] this node covers. Every variant wraps a node with
/// its own `span` field; this is the one place that maps variant → span.
pub fn span(&self) -> Span {
match self {
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,
InlineNode::SoftBreak(n) => n.span,
}
}
}
/// Concatenate the plain-text content of a run of inlines, descending into
/// formatting containers. Links/external links use their description, falling
/// back to the target path / URL. This is the canonical heading/anchor text —
/// the HTML renderer uses it for heading `id`s and the LSP for anchor matching,
/// so the two never drift.
pub fn inline_text(inlines: &[InlineNode]) -> String {
let mut out = String::new();
push_inline_text(inlines, &mut out);
out.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),
},
_ => {}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TextNode {
pub span: Span,
pub content: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SoftBreakNode {
pub span: Span,
}
#[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,
}
/// A named colour span (`color` is a `color_dic` key, `children` the wrapped
/// inline content).
///
/// This is an **extension point**, not a node the vimwiki parser emits: in
/// the editor, colour comes from the `:Colorize` family writing literal
/// `<span style="color:…">` markup, which round-trips through export via
/// `valid_html_tags`. `ColorNode` exists so an alternate syntax (or a future
/// `[color]` markup) can feed the renderer's `color_dic` / `color_tag_template`
/// path directly; see `HtmlRenderer::render_color`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ColorNode {
pub span: Span,
pub color: String,
pub children: Vec<InlineNode>,
}
+66
View File
@@ -0,0 +1,66 @@
//! 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,
}
+54
View File
@@ -0,0 +1,54 @@
//! 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::{
inline_text, BoldItalicNode, BoldNode, CodeNode, ColorNode, InlineNode, ItalicNode, Keyword,
KeywordNode, MathInlineNode, SoftBreakNode, 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>,
}
+33
View File
@@ -0,0 +1,33 @@
//! 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 }
}
}
+230
View File
@@ -0,0 +1,230 @@
//! 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),
// A soft break carries no content/children — treated as whitespace.
InlineNode::SoftBreak(_) => {}
}
}
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);
}
}
+541
View File
@@ -0,0 +1,541 @@
//! 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())
}
}
/// How a *weekly* diary entry is named.
///
/// `Iso` — ISO-week label `YYYY-Www` (nuwiki's original scheme; the week
/// always begins on Monday, `week_start` is ignored).
/// `Date` — the week-start day's calendar date `YYYY-MM-DD`, matching
/// upstream vimwiki; honours `week_start`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WeeklyStyle {
Iso,
Date,
}
impl WeeklyStyle {
/// Parse the config string. `date`/`vimwiki` → `Date`; anything else
/// (including `iso`/`week`/empty) → `Iso`.
pub fn parse(s: &str) -> Self {
match s.trim().to_ascii_lowercase().as_str() {
"date" | "vimwiki" | "day" => Self::Date,
_ => Self::Iso,
}
}
}
/// Day a week begins on, in ISO numbering (Monday = 1 … Sunday = 7).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct WeekStart(u8);
impl WeekStart {
/// Parse a weekday name (`monday`..`sunday`); unknown → Monday.
pub fn parse(s: &str) -> Self {
let n = match s.trim().to_ascii_lowercase().as_str() {
"tuesday" => 2,
"wednesday" => 3,
"thursday" => 4,
"friday" => 5,
"saturday" => 6,
"sunday" => 7,
_ => 1, // monday (default)
};
Self(n)
}
pub fn monday() -> Self {
Self(1)
}
fn iso(self) -> i64 {
self.0 as i64
}
}
/// Diary navigation policy: frequency plus, for weekly diaries, the naming
/// scheme and week-start day. Built from a wiki's `diary_*` config so the
/// LSP commands can compute today / next / prev without re-deriving the
/// rules. `Daily`/`Monthly`/`Yearly` ignore the weekly fields.
#[derive(Debug, Clone, Copy)]
pub struct DiaryCalendar {
pub freq: DiaryFrequency,
pub weekly_style: WeeklyStyle,
pub week_start: WeekStart,
}
impl DiaryCalendar {
pub fn new(freq: DiaryFrequency, weekly_style: WeeklyStyle, week_start: WeekStart) -> Self {
Self {
freq,
weekly_style,
week_start,
}
}
fn is_weekly_date(&self) -> bool {
self.freq == DiaryFrequency::Weekly && self.weekly_style == WeeklyStyle::Date
}
/// "Now" as a diary period for this calendar. Date-mode weekly snaps
/// today back to the most recent week-start day (a plain `Day`, so the
/// file is `YYYY-MM-DD` like upstream); everything else defers to
/// [`DiaryPeriod::today_utc`].
pub fn today(&self) -> DiaryPeriod {
if self.is_weekly_date() {
DiaryPeriod::Day(self.week_start_of(DiaryDate::today_utc()))
} else {
DiaryPeriod::today_utc(self.freq)
}
}
/// The period one cadence-step after `p`.
pub fn next(&self, p: DiaryPeriod) -> DiaryPeriod {
self.step(p, 1)
}
/// The period one cadence-step before `p`.
pub fn prev(&self, p: DiaryPeriod) -> DiaryPeriod {
self.step(p, -1)
}
fn step(&self, p: DiaryPeriod, dir: i64) -> DiaryPeriod {
// Date-mode weekly: the period is a `Day` on a week-start; a step is
// ±7 days (re-snapped so an off-week-start pivot still lands cleanly).
if self.is_weekly_date() {
if let DiaryPeriod::Day(d) = p {
let start = self.week_start_of(d);
return DiaryPeriod::Day(add_days(start, 7 * dir));
}
}
if dir >= 0 {
p.next()
} else {
p.prev()
}
}
/// The most recent `week_start` weekday on or before `d`.
fn week_start_of(&self, d: DiaryDate) -> DiaryDate {
let wd = iso_weekday(&d) as i64; // 1..=7
let back = (wd - self.week_start.iso()).rem_euclid(7);
add_days(d, -back)
}
}
/// Shift a calendar date by `delta` days (may be negative).
fn add_days(d: DiaryDate, delta: i64) -> DiaryDate {
from_days(to_days(d.year, d.month, d.day) + delta)
}
/// 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,
}
}
+10
View File
@@ -0,0 +1,10 @@
//! 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;
+198
View File
@@ -0,0 +1,198 @@
//! 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;
/// Default rejected/cancelled marker (vimwiki's `listsym_rejected`).
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>,
rejected: char,
}
impl Default for ListSyms {
fn default() -> Self {
Self {
progression: " .oOX".chars().collect(),
rejected: REJECTED,
}
}
}
impl ListSyms {
/// Build a palette from a glyph string, with the default rejected marker.
/// 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 {
Self::new_with_rejected(s, REJECTED)
}
/// Like [`new`](Self::new) but with a custom rejected/cancelled glyph
/// (vimwiki's `listsym_rejected`).
pub fn new_with_rejected(s: &str, rejected: char) -> Self {
let progression: Vec<char> = s.chars().collect();
if progression.len() < 2 {
Self {
rejected,
..Self::default()
}
} else {
Self {
progression,
rejected,
}
}
}
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 {
self.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 == self.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 self.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_rejected_glyph_is_honoured() {
// `listsym_rejected = '✗'`: that glyph lexes/snaps as Rejected and the
// default `-` no longer does.
let s = ListSyms::new_with_rejected(" .oOX", '✗');
assert_eq!(s.rejected_glyph(), '✗');
assert_eq!(s.state_of('✗'), Some(CheckboxState::Rejected));
assert_eq!(s.glyph_for_rate(-1), '✗');
assert_eq!(s.state_of('-'), None); // no longer the rejected marker
// A short palette still keeps the custom rejected glyph.
assert_eq!(ListSyms::new_with_rejected("x", '✗').rejected_glyph(), '✗');
}
#[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), '✓');
}
}
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
//! 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))
}
}
+115
View File
@@ -0,0 +1,115 @@
//! 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;
}
+58
View File
@@ -0,0 +1,58 @@
//! 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));
}
/// 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
@@ -0,0 +1,50 @@
//! 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
+278
View File
@@ -0,0 +1,278 @@
//! Cluster 4 — diary frequency / period primitives.
//! Tests the date math for ISO weeks, monthly, yearly periods.
use nuwiki_core::date::{
DiaryCalendar, DiaryDate, DiaryFrequency, DiaryPeriod, WeekStart, WeeklyStyle,
};
#[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())
);
}
// ===== DiaryCalendar — weekly naming styles + week-start =====
// 2026-05-25 is a Monday (so 2026-05-27 is a Wednesday, 2026-05-24 a Sunday).
fn day(y: i32, m: u8, d: u8) -> DiaryPeriod {
DiaryPeriod::Day(DiaryDate::from_ymd(y, m, d).unwrap())
}
#[test]
fn weekly_style_and_week_start_parse() {
assert_eq!(WeeklyStyle::parse("date"), WeeklyStyle::Date);
assert_eq!(WeeklyStyle::parse("vimwiki"), WeeklyStyle::Date);
assert_eq!(WeeklyStyle::parse("iso"), WeeklyStyle::Iso);
assert_eq!(WeeklyStyle::parse(""), WeeklyStyle::Iso); // default
assert_eq!(WeeklyStyle::parse("WEEK"), WeeklyStyle::Iso);
// WeekStart::parse is exercised via the calendar tests below; unknown
// names fall back to Monday.
assert_eq!(WeekStart::parse("sunday"), WeekStart::parse("SUNDAY"));
assert_eq!(WeekStart::parse("nonsense"), WeekStart::monday());
}
#[test]
fn date_mode_weekly_today_is_a_day() {
let cal = DiaryCalendar::new(
DiaryFrequency::Weekly,
WeeklyStyle::Date,
WeekStart::monday(),
);
assert!(matches!(cal.today(), DiaryPeriod::Day(_)));
}
#[test]
fn iso_mode_weekly_today_is_a_week() {
let cal = DiaryCalendar::new(
DiaryFrequency::Weekly,
WeeklyStyle::Iso,
WeekStart::monday(),
);
assert!(matches!(cal.today(), DiaryPeriod::Week { .. }));
}
#[test]
fn date_mode_weekly_steps_seven_days_from_the_week_start_monday() {
let cal = DiaryCalendar::new(
DiaryFrequency::Weekly,
WeeklyStyle::Date,
WeekStart::monday(),
);
// From a Wednesday: snap back to Mon 05-25, then ±7.
assert_eq!(cal.next(day(2026, 5, 27)), day(2026, 6, 1));
assert_eq!(cal.prev(day(2026, 5, 27)), day(2026, 5, 18));
// From the Monday itself: no snap, just ±7.
assert_eq!(cal.next(day(2026, 5, 25)), day(2026, 6, 1));
assert_eq!(cal.prev(day(2026, 5, 25)), day(2026, 5, 18));
// Stem is the plain date, like upstream.
assert_eq!(cal.next(day(2026, 5, 27)).format(), "2026-06-01");
}
#[test]
fn date_mode_weekly_honours_a_sunday_week_start() {
let cal = DiaryCalendar::new(
DiaryFrequency::Weekly,
WeeklyStyle::Date,
WeekStart::parse("sunday"),
);
// Wed 05-27 snaps back to Sun 05-24, +7 = Sun 05-31.
assert_eq!(cal.next(day(2026, 5, 27)), day(2026, 5, 31));
assert_eq!(cal.prev(day(2026, 5, 27)), day(2026, 5, 17));
}
#[test]
fn iso_mode_weekly_delegates_to_iso_week_stepping() {
let cal = DiaryCalendar::new(
DiaryFrequency::Weekly,
WeeklyStyle::Iso,
WeekStart::monday(),
);
let wk = DiaryPeriod::week_containing(DiaryDate::from_ymd(2026, 5, 27).unwrap());
assert_eq!(cal.next(wk), wk.next());
assert_eq!(cal.prev(wk), wk.prev());
}
#[test]
fn daily_calendar_steps_one_day_regardless_of_weekly_fields() {
let cal = DiaryCalendar::new(
DiaryFrequency::Daily,
WeeklyStyle::Date,
WeekStart::parse("sunday"),
);
assert_eq!(cal.next(day(2026, 5, 27)), day(2026, 5, 28));
assert_eq!(cal.prev(day(2026, 5, 27)), day(2026, 5, 26));
}
+505
View File
@@ -0,0 +1,505 @@
//! 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} id=\"Title\">Title</h{level}>")));
}
}
#[test]
fn centered_heading_gets_centered_class() {
let out = render(" = T =\n");
assert!(out.contains("<h1 class=\"centered\" id=\"T\">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&lt;u32&gt;</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 heading_id_matches_anchor_link_href() {
// The heading carries an `id` equal to its plain text, so a `#anchor`
// link (TOC or cross-reference) actually lands on it. Regression guard
// for "exported HTML anchor links don't work".
let out = render("= My Section =\n\n[[#My Section]]\n");
assert!(
out.contains("<h1 id=\"My Section\">My Section</h1>"),
"heading id: {out}"
);
assert!(
out.contains("<a href=\"#My Section\">"),
"anchor href: {out}"
);
}
#[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 &lt; b &gt; c &amp; d &quot;e&quot;"));
}
// ===== 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 id=\"Hello\">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 id=\"Heading\">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 id=\"Hello\">Hello</h1>"),
"content: {out}"
);
assert!(out.contains("href=\"../style.css\""), "root_path: {out}");
assert!(!out.contains('%'), "no placeholder left: {out}");
}
// ===== Colour spans =====
// `ColorNode` is an extension point the vimwiki parser never emits, so build
// a document around one by hand and render it directly.
fn doc_with_color(color: &str) -> DocumentNode {
use nuwiki_core::ast::{ColorNode, ParagraphNode};
let color_node = InlineNode::Color(ColorNode {
span: Span::default(),
color: color.to_string(),
children: vec![InlineNode::Text(TextNode {
span: Span::default(),
content: "hi".to_string(),
})],
});
DocumentNode {
children: vec![BlockNode::Paragraph(ParagraphNode {
span: Span::default(),
children: vec![color_node],
})],
..DocumentNode::default()
}
}
#[test]
fn color_dic_name_uses_inline_style_via_default_template() {
let doc = doc_with_color("red");
let renderer =
HtmlRenderer::new().with_colors([("red".to_string(), "crimson".to_string())].into());
let out = renderer.render_to_string(&doc).unwrap();
assert!(
out.contains("<span style=\"color:crimson\">hi</span>"),
"got: {out}"
);
}
#[test]
fn color_tag_template_override_is_honored() {
let doc = doc_with_color("red");
let renderer = HtmlRenderer::new()
.with_colors([("red".to_string(), "crimson".to_string())].into())
.with_color_template("<em data-style=\"__STYLE__\">__CONTENT__</em>");
let out = renderer.render_to_string(&doc).unwrap();
assert!(
out.contains("<em data-style=\"color:crimson\">hi</em>"),
"got: {out}"
);
}
#[test]
fn unknown_color_name_falls_back_to_class() {
let doc = doc_with_color("plaid");
let out = HtmlRenderer::new().render_to_string(&doc).unwrap();
assert!(
out.contains("<span class=\"color-plaid\">hi</span>"),
"got: {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 id=\"Heading\">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="));
}
+639
View File
@@ -0,0 +1,639 @@
//! 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);
}
+108
View File
@@ -0,0 +1,108 @@
//! 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)),
// Soft breaks join continuation lines (formerly a Text(" ")).
InlineNode::SoftBreak(_) => out.push(' '),
_ => {}
}
}
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}");
}
+592
View File
@@ -0,0 +1,592 @@
//! 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"), SoftBreak (from soft newline), Text("world").
let text_concat: String = p
.children
.iter()
.filter_map(|n| match n {
InlineNode::Text(t) => Some(t.content.clone()),
InlineNode::SoftBreak(_) => Some(" ".to_string()),
_ => 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(_)));
}
+144
View File
@@ -0,0 +1,144 @@
//! 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>();
}
@@ -0,0 +1,57 @@
//! 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"));
}
+204
View File
@@ -0,0 +1,204 @@
//! 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&lt;b"));
}
+60
View File
@@ -0,0 +1,60 @@
//! 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 `&quot;` 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 &para.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("&quot;"));
}
+215
View File
@@ -0,0 +1,215 @@
//! 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);
}
+21
View File
@@ -0,0 +1,21 @@
[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"] }
+6
View File
@@ -0,0 +1,6 @@
//! `nuwiki-ls` — language-server binary. Speaks LSP over stdio.
#[tokio::main]
async fn main() -> std::io::Result<()> {
nuwiki_lsp::run_stdio().await
}
+22
View File
@@ -0,0 +1,22 @@
[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
File diff suppressed because it is too large Load Diff
+335
View File
@@ -0,0 +1,335 @@
//! 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, and
/// (via `nuwiki_core::ast::inline_text`) the same text the HTML renderer uses
/// for heading `id`s — so anchors validated here match anchors emitted there.
pub fn heading_text(inlines: &[InlineNode]) -> String {
nuwiki_core::ast::inline_text(inlines)
}
/// 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
}
+329
View File
@@ -0,0 +1,329 @@
//! 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. A `caption_level < 0`
/// clamps to `0` (nuwiki builds the tree from dates, so upstream's `-1`
/// "no per-page captions" mode doesn't apply). `months` supplies the
/// month-number → display name table; an empty slice (or a short list)
/// falls back to the English `month_name` for any missing slot. `sort`
/// controls whether the flat list runs newest- or oldest-first. `margin`
/// is the number of leading spaces before each entry bullet (vimwiki's
/// `list_margin`).
pub fn build_index_body(
entries: &[DiaryEntry],
diary_rel_path: &str,
index_heading: &str,
sort: DiarySort,
caption_level: i8,
months: &[String],
margin: usize,
) -> String {
let base_level = caption_level.max(0) as u8;
let pad = " ".repeat(margin);
let mut out = String::new();
out.push_str(&heading(base_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 = base_level.saturating_add(1);
let month_level = base_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) {
let label = months
.get((e.date.month.saturating_sub(1)) as usize)
.map(String::as_str)
.filter(|s| !s.is_empty())
.unwrap_or_else(|| month_name(e.date.month));
out.push_str(&heading(month_level, label));
out.push('\n');
current_month = Some(e.date.month);
}
out.push_str(&format!(
"{pad}- [[{}/{}]]\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`, `diary_caption_level`, and
/// `diary_months`.
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,
&cfg.diary_months,
cfg.list_margin.max(0) as usize,
)
}
/// 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",
}
}
+151
View File
@@ -0,0 +1,151 @@
//! 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 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
}
+394
View File
@@ -0,0 +1,394 @@
//! 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::PathBuf;
use nuwiki_core::ast::{BlockNode, DocumentNode, LinkKind};
use nuwiki_core::date::DiaryDate;
use nuwiki_core::render::{HtmlRenderer, Renderer};
use crate::config::HtmlConfig;
/// 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;
}
// Anchor is the raw heading text (vimwiki scheme), HTML-escaped — must
// match the `id` the renderer puts on the heading element.
let anchor = escape_html(&h.title);
out.push_str(&format!("<li><a href=\"#{anchor}\">{anchor}</a></li>"));
}
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>,
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();
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());
}
if !cfg.color_tag_template.is_empty() {
r = r.with_color_template(cfg.color_tag_template.clone());
}
if cfg.html_header_numbering > 0 {
r = r.with_header_numbering(
cfg.html_header_numbering,
cfg.html_header_numbering_sym.clone(),
);
}
if !cfg.valid_html_tags.is_empty() {
r = r.with_valid_html_tags(cfg.valid_html_tags.clone());
}
r = r.with_emoji(cfg.emoji_enable);
r = r.with_newline_handling(cfg.text_ignore_newline, cfg.list_ignore_newline);
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: nuwiki_core::ast::inline_text(&h.children),
});
}
}
out
}
struct HeadingProj {
level: u8,
title: String,
}
fn escape_html(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
match ch {
'&' => out.push_str("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'"' => out.push_str("&quot;"),
_ => 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";
+88
View File
@@ -0,0 +1,88 @@
//! `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
}
}
+656
View File
@@ -0,0 +1,656 @@
//! 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>>,
/// For each source URI, the backlink-bucket keys and tag-bucket names it
/// contributed. Lets `remove` touch only the affected buckets instead of
/// scanning every bucket in the workspace (O(touched), not O(total)).
contributions: HashMap<Url, SourceContributions>,
}
/// The bucket keys a single source page wrote into, so they can be undone
/// in `remove` without a full scan. See [`WorkspaceIndex::contributions`].
#[derive(Debug, Default)]
struct SourceContributions {
backlink_keys: Vec<String>,
tag_names: Vec<String>,
}
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 mut contrib = SourceContributions::default();
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.clone())
.or_default()
.push(Backlink {
source_uri: uri.clone(),
source_span: link.span,
target_anchor: link.anchor.clone(),
});
contrib.backlink_keys.push(key);
}
}
// 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,
});
contrib.tag_names.push(tag.name.clone());
}
self.contributions.insert(uri.clone(), contrib);
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);
}
// Only revisit the buckets this source actually wrote into, instead
// of scanning every backlink/tag bucket in the workspace.
if let Some(contrib) = self.contributions.remove(uri) {
for key in contrib.backlink_keys {
if let Some(entries) = self.backlinks.get_mut(&key) {
entries.retain(|b| &b.source_uri != uri);
if entries.is_empty() {
self.backlinks.remove(&key);
}
}
}
for name in contrib.tag_names {
if let Some(entries) = self.tags_by_name.get_mut(&name) {
entries.retain(|t| &t.uri != uri);
if entries.is_empty() {
self.tags_by_name.remove(&name);
}
}
}
}
}
/// 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 [`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
///
/// 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),
InlineNode::SoftBreak(_) => out.push(' '),
}
}
}
// ===== 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
+129
View File
@@ -0,0 +1,129 @@
//! 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(n.span(), 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
}
+135
View File
@@ -0,0 +1,135 @@
//! `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)
}
+369
View File
@@ -0,0 +1,369 @@
//! 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(first.span().start, last.span().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(_) | InlineNode::SoftBreak(_) => 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);
}
}
/// 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
}
+80
View File
@@ -0,0 +1,80 @@
//! `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,
// `Arc` so cloning a `Wiki` (done on every handler entry that picks a
// wiki out of the list) is a refcount bump, not a deep copy of the whole
// config — color_dic, templates, header strings, and so on.
pub config: Arc<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: Arc::new(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()
}
@@ -0,0 +1,524 @@
//! 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())]);
}
@@ -0,0 +1,75 @@
//! `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"));
}
@@ -0,0 +1,591 @@
//! 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,
&[],
0,
);
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, "Contents", 1, 0, 0).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",
1,
0,
0,
);
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", 1, Some("Home"), 0, None);
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,
"Generated Links",
1,
0,
None
)
.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"), "Generated Tags", 1, 0).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, "Generated Tags", 1, 0).unwrap();
assert!(all.starts_with("= Generated 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,
"Generated Tags",
1,
0
)
.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"),
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"),
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}");
}
}
+115
View File
@@ -0,0 +1,115 @@
//! 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:?}"),
}
}
@@ -0,0 +1,83 @@
//! 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 ==");
}
+126
View File
@@ -0,0 +1,126 @@
//! 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"));
}
+348
View File
@@ -0,0 +1,348 @@
//! 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, 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, 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, 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, None, 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, None, 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, None, true).expect("edit");
let edits = &edit.changes.unwrap()[&uri()];
assert_eq!(edits.len(), 1, "the nested done child is removed");
}
#[test]
fn remove_done_ranged_restricts_to_selected_lines() {
// The `:'<,'>VimwikiRemoveDone` form: a 0-indexed inclusive line range
// sweeps the whole doc but only deletes done/rejected items inside it.
// Lines: 0 `[X]`, 1 `[ ]`, 2 `[X]`, 3 `[-]`. Range 0..=1 keeps only the
// line-0 done item; the line-2/3 done+rejected items survive.
let src = "- [X] a\n- [ ] b\n- [X] c\n- [-] d\n";
let ast = parse(src);
let edit = ops::remove_done_edit(src, &ast, &uri(), None, Some((0, 1)), true).expect("edit");
let edits = &edit.changes.unwrap()[&uri()];
assert_eq!(edits.len(), 1, "only the done item on line 0 is in range");
}
#[test]
fn remove_done_ranged_returns_none_when_range_has_no_done() {
// Range covering only open items → nothing to delete.
let src = "- [X] a\n- [ ] b\n- [ ] c\n";
let ast = parse(src);
assert!(ops::remove_done_edit(src, &ast, &uri(), None, Some((1, 2)), true).is_none());
}
// ===== 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, false, &[]).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, false, &[]);
// Already at column 0 — dedent is a no-op.
assert!(edit.is_none());
}
#[test]
fn cycle_bullets_rotates_glyph_on_indent() {
// With cycle_bullets + bullet_types [-,*,#], indenting a `-` item (depth 0
// → 1) rotates the glyph to `*` (and dedenting back to `-`).
let bullets: Vec<String> = ["-", "*", "#"].iter().map(|s| s.to_string()).collect();
// Put the list well into the document so depth must come from the item's
// indentation, not its absolute byte offset (regression guard).
let src = "some intro paragraph text here\n\n- one\n- two\n";
let ast = parse(src);
let edit =
ops::change_level_edit(src, &ast, &uri(), 3, 1, false, true, true, &bullets).expect("edit");
let texts: Vec<String> = edit.changes.unwrap()[&uri()]
.iter()
.map(|e| e.new_text.clone())
.collect();
assert!(texts.contains(&" ".to_string()), "indent edit: {texts:?}");
assert!(
texts.contains(&"*".to_string()),
"glyph rotated to *: {texts:?}"
);
}
#[test]
fn cycle_bullets_off_leaves_glyph() {
let bullets: Vec<String> = ["-", "*", "#"].iter().map(|s| s.to_string()).collect();
let src = "- one\n- two\n";
let ast = parse(src);
let edit = ops::change_level_edit(src, &ast, &uri(), 1, 1, false, true, false, &bullets)
.expect("edit");
let texts: Vec<String> = edit.changes.unwrap()[&uri()]
.iter()
.map(|e| e.new_text.clone())
.collect();
// Only the indent edit; no glyph rewrite.
assert!(
!texts.iter().any(|t| t == "*"),
"no glyph change: {texts:?}"
);
}
#[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, false, &[]).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, None, true).unwrap();
let _ = one_edit_text(edit);
}
+132
View File
@@ -0,0 +1,132 @@
//! 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, false).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, false).is_none());
}
#[test]
fn table_reduce_last_col_keeps_last_column_minimal() {
// With reduce_last_col=true, the last column isn't padded to its content
// width — it stays at the minimum (1), so `three` is not surrounded by
// alignment padding on the right edge.
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, true).expect("edit");
let body = one_text(edit);
let lines: Vec<&str> = body.lines().collect();
// First two columns still align; the last column stays at width 1 (not 5),
// so the header's last cell renders `| c |` not `| c |`.
assert_eq!(lines[0], "| a | bbb | c |", "got: {:?}", lines[0]);
assert_eq!(lines[2], "| 1 | 2 | three |", "got: {:?}", lines[2]);
}
// ===== 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, false).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, false);
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, false).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}");
}
}
+372
View File
@@ -0,0 +1,372 @@
//! 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"), "Generated Tags", 1, 0).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"), "Generated Tags", 1, 0).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, "Generated Tags", 1, 0).unwrap();
assert!(out.starts_with("= Generated 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, "Generated Tags", 1, 0).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,
"Generated Tags",
1,
0,
)
.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,
"Generated Tags",
1,
0,
)
.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 = "= Generated 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, "Generated Tags", 1, 0)
.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_rebuild_edit_only_acts_when_index_present() {
let mut snap = BTreeMap::new();
snap.insert("a".to_string(), vec!["P".into()]);
let uri = Url::parse("file:///tmp/p.wiki").unwrap();
let without = "= Home =\nbody\n";
assert!(
ops::tag_links_rebuild_edit(
without,
&parse(without),
&uri,
&snap,
true,
"Generated Tags",
1,
0
)
.is_none(),
"auto_generate_tags must not insert an index into a page that lacks one"
);
let with = "= Generated Tags =\n- [[old]]\n";
assert!(ops::tag_links_rebuild_edit(
with,
&parse(with),
&uri,
&snap,
true,
"Generated Tags",
1,
0
)
.is_some());
}
#[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,
"Generated Tags",
1,
0
)
.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}");
}
}
+71
View File
@@ -0,0 +1,71 @@
//! 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}");
}
}
+767
View File
@@ -0,0 +1,767 @@
//! 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,
&[],
0,
);
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, &[], 0);
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, &[], 0);
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,
&[],
0,
);
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,
&[],
0,
);
assert!(body.contains("====== Diary ======"));
assert!(!body.contains("======="), "no heading exceeds level 6");
}
#[test]
fn build_index_body_uses_custom_diary_months() {
let idx = build_index_with_entries("/tmp/diaryMonths", &["2026-05-11", "2026-12-01"]);
let entries = diary::list_entries(&idx);
// A localized month table (Portuguese) replaces the English labels.
let months: Vec<String> = [
"Janeiro",
"Fevereiro",
"Março",
"Abril",
"Maio",
"Junho",
"Julho",
"Agosto",
"Setembro",
"Outubro",
"Novembro",
"Dezembro",
]
.iter()
.map(|s| s.to_string())
.collect();
let body = diary::build_index_body(
&entries,
"diary",
"Diary",
diary::DiarySort::Desc,
1,
&months,
0,
);
assert!(body.contains("=== Maio ==="), "May → Maio: {body}");
assert!(
body.contains("=== Dezembro ==="),
"December → Dezembro: {body}"
);
assert!(!body.contains("May"), "English label leaked: {body}");
}
#[test]
fn build_index_body_falls_back_per_missing_month_slot() {
let idx = build_index_with_entries("/tmp/diaryShort", &["2026-05-11"]);
let entries = diary::list_entries(&idx);
// A too-short table only covers JanMar; May falls back to English.
let months: Vec<String> = ["Jan", "Feb", "Mar"]
.iter()
.map(|s| s.to_string())
.collect();
let body = diary::build_index_body(
&entries,
"diary",
"Diary",
diary::DiarySort::Desc,
1,
&months,
0,
);
assert!(
body.contains("=== May ==="),
"missing slot → English: {body}"
);
}
#[test]
fn build_index_body_clamps_negative_caption_level_to_zero() {
let idx = build_index_with_entries("/tmp/diaryNeg", &["2026-05-11"]);
let entries = diary::list_entries(&idx);
// vimwiki allows diary_caption_level = -1; nuwiki clamps it to 0 (same as
// a caption_level of 0: caption h1, year h1, month h2).
let body = diary::build_index_body(
&entries,
"diary",
"Diary",
diary::DiarySort::Desc,
-1,
&[],
0,
);
assert!(body.contains("= Diary ="), "caption at level 1: {body}");
assert!(body.contains("= 2026 ="), "year at level 1: {body}");
assert!(body.contains("== May =="), "month at level 2: {body}");
}
#[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}");
}
}
+111
View File
@@ -0,0 +1,111 @@
//! 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);
}
+255
View File
@@ -0,0 +1,255 @@
//! 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);
}
+785
View File
@@ -0,0 +1,785 @@
//! 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());
assert_eq!(c.html_header_numbering, 0); // upstream default: numbering off
assert!(c.html_header_numbering_sym.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 &amp; B &lt;ok&gt;"));
}
// ===== 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_passes_through_valid_html_tags() {
// `<sub>`/`<kbd>` are in the default valid_html_tags → verbatim; `<script>`
// is not → escaped.
let ast = parse("H<sub>2</sub>O press <kbd>Ctrl</kbd> <script>x</script>\n");
let c = cfg("/tmp/x");
let html = export::render_page_html(
&ast,
Some("{{content}}".into()),
"Home",
DiaryDate::today_utc(),
&c.html,
None,
HashMap::new(),
)
.unwrap();
assert!(html.contains("H<sub>2</sub>O"), "sub verbatim: {html}");
assert!(html.contains("<kbd>Ctrl</kbd>"), "kbd verbatim: {html}");
assert!(html.contains("&lt;script&gt;"), "script escaped: {html}");
}
#[test]
fn render_page_html_ignore_newline_controls_soft_breaks() {
// Paragraph + list item, each with a soft (single) newline inside.
let ast = parse("alpha\nbeta\n\n- one\n two\n");
// Default (true/true): soft breaks render as spaces, no <br>.
let c = cfg("/tmp/x");
let def = export::render_page_html(
&ast,
Some("{{content}}".into()),
"Home",
DiaryDate::today_utc(),
&c.html,
None,
HashMap::new(),
)
.unwrap();
assert!(!def.contains("<br"), "default joins with spaces: {def}");
assert!(def.contains("alpha beta"), "para space: {def}");
// false/false: soft breaks become <br /> in both paragraph and list item.
let mut c2 = cfg("/tmp/x");
c2.html.text_ignore_newline = false;
c2.html.list_ignore_newline = false;
let br = export::render_page_html(
&ast,
Some("{{content}}".into()),
"Home",
DiaryDate::today_utc(),
&c2.html,
None,
HashMap::new(),
)
.unwrap();
assert!(br.contains("alpha<br />beta"), "para <br>: {br}");
assert!(
br.contains("one<br />two") || br.contains("one<br /> two"),
"list <br>: {br}"
);
}
#[test]
fn render_page_html_substitutes_emoji_when_enabled() {
let ast = parse("ship it :rocket: and :tada:\n");
let c = cfg("/tmp/x"); // emoji_enable defaults true
let html = export::render_page_html(
&ast,
Some("{{content}}".into()),
"Home",
DiaryDate::today_utc(),
&c.html,
None,
HashMap::new(),
)
.unwrap();
assert!(html.contains("🚀"), "rocket: {html}");
assert!(html.contains("🎉"), "tada: {html}");
assert!(!html.contains(":rocket:"), "shortcode replaced: {html}");
}
#[test]
fn render_page_html_emoji_off_leaves_shortcodes() {
let ast = parse("plain :rocket: here\n");
let mut c = cfg("/tmp/x");
c.html.emoji_enable = false;
let html = export::render_page_html(
&ast,
Some("{{content}}".into()),
"Home",
DiaryDate::today_utc(),
&c.html,
None,
HashMap::new(),
)
.unwrap();
assert!(html.contains(":rocket:"), "left as-is: {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,
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,
HashMap::new(),
)
.unwrap();
assert!(html.contains("DATE=2025-01-02"), "got: {html}");
}
#[test]
fn render_page_html_numbers_headers_when_enabled() {
let ast = parse("= Intro =\n== Background ==\n== Methods ==\n= Results =\n");
let mut c = cfg("/tmp/x");
c.html.html_header_numbering = 1; // number from h1
c.html.html_header_numbering_sym = ".".into();
let html = export::render_page_html(
&ast,
None,
"Home",
DiaryDate::from_ymd(2026, 5, 11).unwrap(),
&c.html,
None,
HashMap::new(),
)
.unwrap();
assert!(
html.contains("<h1 id=\"Intro\">1. Intro</h1>"),
"got: {html}"
);
assert!(
html.contains("<h2 id=\"Background\">1.1. Background</h2>"),
"got: {html}"
);
assert!(
html.contains("<h2 id=\"Methods\">1.2. Methods</h2>"),
"got: {html}"
);
assert!(
html.contains("<h1 id=\"Results\">2. Results</h1>"),
"got: {html}"
);
}
#[test]
fn render_page_html_numbering_skips_headers_above_start_level() {
let ast = parse("= Title =\n== Section ==\n=== Sub ===\n");
let mut c = cfg("/tmp/x");
c.html.html_header_numbering = 2; // start at h2; h1 stays unnumbered
// empty sym → number followed by a bare space
let html = export::render_page_html(
&ast,
None,
"Home",
DiaryDate::from_ymd(2026, 5, 11).unwrap(),
&c.html,
None,
HashMap::new(),
)
.unwrap();
assert!(
html.contains("<h1 id=\"Title\">Title</h1>"),
"h1 unnumbered, got: {html}"
);
assert!(
html.contains("<h2 id=\"Section\">1 Section</h2>"),
"got: {html}"
);
assert!(html.contains("<h3 id=\"Sub\">1.1 Sub</h3>"), "got: {html}");
}
#[test]
fn render_page_html_no_header_numbering_by_default() {
let ast = parse("= Intro =\n== Sub ==\n");
let c = cfg("/tmp/x");
let html = export::render_page_html(
&ast,
None,
"Home",
DiaryDate::from_ymd(2026, 5, 11).unwrap(),
&c.html,
None,
HashMap::new(),
)
.unwrap();
assert!(html.contains("<h1 id=\"Intro\">Intro</h1>"), "got: {html}");
assert!(html.contains("<h2 id=\"Sub\">Sub</h2>"), "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,
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,
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,
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"),
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}");
}
// ===== 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, false).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, false).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, false).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);
// No base_url → item links keep the file:// URI.
assert!(xml.contains("<link>file:///tmp/diary/2026-05-11.wiki</link>"));
}
#[test]
fn write_rss_uses_base_url_for_public_links() {
use nuwiki_lsp::diary::DiaryEntry;
use tower_lsp::lsp_types::Url;
let root = temp_root("rss_base");
let mut c = WikiConfig::from_root(root.clone());
c.html = HtmlConfig::from_root(&root);
c.html.base_url = "https://example.com/wiki/".into();
c.diary_rel_path = "diary".into();
let entries = vec![DiaryEntry {
date: DiaryDate::from_ymd(2026, 5, 11).unwrap(),
uri: Url::parse("file:///tmp/diary/2026-05-11.wiki").unwrap(),
}];
let path = nuwiki_lsp::commands::export_ops::write_rss(&c, &entries).unwrap();
let xml = std::fs::read_to_string(&path).unwrap();
// Channel link points at the diary index page (upstream fidelity); item
// link is the public per-entry URL.
assert!(
xml.contains("<link>https://example.com/wiki/diary/diary.html</link>"),
"channel link: {xml}"
);
assert!(xml.contains("<link>https://example.com/wiki/diary/2026-05-11.html</link>"));
// guid is the bare date, isPermaLink=false.
assert!(
xml.contains("<guid isPermaLink=\"false\">2026-05-11</guid>"),
"guid: {xml}"
);
// atom:link self + a pubDate are present.
assert!(xml.contains("rel=\"self\""), "atom:link: {xml}");
assert!(xml.contains("<pubDate>"), "pubDate: {xml}");
assert!(!xml.contains("file:///tmp/diary"));
}
#[test]
fn prune_orphan_html_deletes_sourceless_keeps_protected() {
let root = temp_root("prune");
let mut c = WikiConfig::from_root(root.clone());
c.html = HtmlConfig::from_root(&root);
c.html.user_htmls = vec!["keep.html".into()];
let html = &c.html.html_path;
std::fs::create_dir_all(html).unwrap();
// A sourced page (Home.wiki exists) → kept.
std::fs::write(root.join("Home.wiki"), "= Home =\n").unwrap();
std::fs::write(html.join("Home.html"), "<h1>Home</h1>").unwrap();
// An orphan (no source) → pruned.
std::fs::write(html.join("Ghost.html"), "<h1>Ghost</h1>").unwrap();
// A user_htmls-protected orphan → kept.
std::fs::write(html.join("keep.html"), "<p>manual</p>").unwrap();
// The CSS file → never pruned.
std::fs::write(html.join(&c.html.css_name), "body{}").unwrap();
let pruned = nuwiki_lsp::commands::export_ops::prune_orphan_html(&c);
assert_eq!(pruned.len(), 1, "only the orphan: {pruned:?}");
assert!(!html.join("Ghost.html").exists(), "orphan deleted");
assert!(html.join("Home.html").exists(), "sourced kept");
assert!(html.join("keep.html").exists(), "user_htmls kept");
assert!(html.join(&c.html.css_name).exists(), "css kept");
}
#[test]
fn write_rss_honours_rss_name_and_max_items() {
use nuwiki_lsp::diary::DiaryEntry;
use tower_lsp::lsp_types::Url;
let root = temp_root("rss_cap");
let mut c = WikiConfig::from_root(root.clone());
c.html = HtmlConfig::from_root(&root);
c.html.rss_name = "feed.xml".into();
c.html.rss_max_items = 2;
let entries: Vec<DiaryEntry> = (1..=5)
.map(|d| DiaryEntry {
date: DiaryDate::from_ymd(2026, 5, d).unwrap(),
uri: Url::parse(&format!("file:///tmp/diary/2026-05-0{d}.wiki")).unwrap(),
})
.collect();
let path = nuwiki_lsp::commands::export_ops::write_rss(&c, &entries).unwrap();
assert!(path.ends_with("feed.xml"), "rss_name: {path:?}");
let xml = std::fs::read_to_string(&path).unwrap();
// Capped at 2 items (newest first: 05-05, 05-04).
assert_eq!(xml.matches("<item>").count(), 2, "cap: {xml}");
assert!(xml.contains("2026-05-05") && xml.contains("2026-05-04"));
assert!(!xml.contains("2026-05-03"), "older items dropped: {xml}");
}
#[test]
fn write_page_invokes_custom_wiki2html_converter() {
let root = temp_root("custom_w2h");
let mut c = WikiConfig::from_root(root.clone());
c.html = HtmlConfig::from_root(&root);
// Source file must exist on disk — the converter receives its path.
std::fs::write(root.join("Hello.wiki"), "= Hello =\nworld\n").unwrap();
// A tiny shell "converter": writes a marker into the output path it is told
// to produce. nuwiki appends args positionally after `_` ($0), so
// $1=force, $2=syntax, $3=ext, $4=output_dir, $5=input_file, …
let script = "mkdir -p \"$4\" && printf 'CUSTOM:%s' \"$5\" > \"$4\"/Hello.html";
c.html.custom_wiki2html = format!("sh -c {} _", shell_words_quote(script));
let outcome =
nuwiki_lsp::commands::export_ops::write_page(&parse("= Hello =\n"), "Hello", &c, true)
.unwrap();
assert_eq!(outcome.page, "Hello");
assert!(
outcome.output_path.exists(),
"converter must produce output"
);
let body = std::fs::read_to_string(&outcome.output_path).unwrap();
assert!(
body.starts_with("CUSTOM:"),
"marker from converter, got {body}"
);
}
/// Minimal single-quote shell escaping for the test command above.
fn shell_words_quote(s: &str) -> String {
format!("'{}'", s.replace('\'', "'\\''"))
}
// ===== 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}");
}
}
+654
View File
@@ -0,0 +1,654 @@
//! 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, 0); // upstream default
assert_eq!(cfg.diary_sort, "desc");
assert_eq!(cfg.diary_header, "Diary");
// diary_months defaults to the 12 English month names.
assert_eq!(cfg.diary_months.len(), 12);
assert_eq!(cfg.diary_months[0], "January");
assert_eq!(cfg.diary_months[11], "December");
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);
assert!(!cfg.auto_generate_links);
assert!(!cfg.auto_generate_tags);
assert!(!cfg.auto_diary_index);
// Generated-section captions match upstream defaults.
assert_eq!(cfg.toc_header, "Contents");
assert_eq!(cfg.toc_header_level, 1);
assert_eq!(cfg.links_header, "Generated Links");
assert_eq!(cfg.tags_header, "Generated Tags");
assert_eq!(cfg.listsym_rejected, "-");
// No external converter / public base URL by default.
assert_eq!(cfg.html.custom_wiki2html, "");
assert_eq!(cfg.html.custom_wiki2html_args, "");
assert_eq!(cfg.html.base_url, "");
// HTML section numbering is off by default (vimwiki's `0`).
assert_eq!(cfg.html.html_header_numbering, 0);
assert_eq!(cfg.html.html_header_numbering_sym, "");
// New config defaults match upstream.
assert!(cfg.create_link);
assert_eq!(cfg.dir_link, "");
assert_eq!(cfg.bullet_types, vec!["-", "*", "#"]);
assert!(!cfg.cycle_bullets);
assert!(!cfg.generated_links_caption);
assert_eq!(cfg.toc_link_format, 0);
assert!(!cfg.table_reduce_last_col);
assert_eq!(cfg.html.rss_name, "rss.xml");
assert_eq!(cfg.html.rss_max_items, 10);
assert!(cfg.html.list_ignore_newline);
assert!(cfg.html.text_ignore_newline);
assert!(cfg.html.emoji_enable);
assert!(cfg.html.user_htmls.is_empty());
assert!(cfg.html.valid_html_tags.contains(&"sub".to_string()));
// color_dic ships a populated palette (vimwiki seeds one).
assert!(cfg.html.color_dic.contains_key("red"));
}
#[test]
fn raw_wiki_parses_config_batch_keys() {
let cfg = config_from_json(serde_json::json!({
"wikis": [{
"root": "/tmp/w",
"create_link": false,
"dir_link": "index",
"bullet_types": ["+", "-"],
"cycle_bullets": true,
"generated_links_caption": 1,
"toc_link_format": 1,
"table_reduce_last_col": true,
"rss_name": "feed.xml",
"rss_max_items": 5,
"valid_html_tags": "b,mark",
"list_ignore_newline": 0,
"text_ignore_newline": false,
"emoji_enable": 0,
"user_htmls": ["404.html"],
"color_tag_template": "<u>__CONTENT__</u>",
}],
}));
let w = &cfg.wikis[0];
assert!(!w.create_link);
assert_eq!(w.dir_link, "index");
assert_eq!(w.bullet_types, vec!["+", "-"]);
assert!(w.cycle_bullets);
assert!(w.generated_links_caption);
assert_eq!(w.toc_link_format, 1);
assert!(w.table_reduce_last_col);
assert_eq!(w.html.rss_name, "feed.xml");
assert_eq!(w.html.rss_max_items, 5);
assert_eq!(w.html.valid_html_tags, vec!["b", "mark"]);
assert!(!w.html.list_ignore_newline);
assert!(!w.html.text_ignore_newline);
assert!(!w.html.emoji_enable);
assert_eq!(w.html.user_htmls, vec!["404.html"]);
assert_eq!(w.html.color_tag_template, "<u>__CONTENT__</u>");
}
#[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_weekly_style": "date",
"diary_start_week_day": "sunday",
"diary_caption_level": 2,
"diary_sort": "asc",
"diary_header": "Journal",
"diary_months": ["Jan","Feb","Mar","Apr","Mai","Jun","Jul","Ago","Set","Out","Nov","Dez"],
"listsyms": "abcde",
"listsym_rejected": "",
"listsyms_propagate": false,
"list_margin": 2,
"links_space_char": "_",
"auto_toc": true,
"auto_generate_links": true,
"auto_generate_tags": 1,
"auto_diary_index": true,
"toc_header": "Table of Contents",
"toc_header_level": 2,
"links_header": "All Pages",
"tags_header": "Tag Index",
"tags_header_level": 3,
"custom_wiki2html": "~/bin/my_wiki2html.sh",
"custom_wiki2html_args": "--flag",
"base_url": "https://example.com/wiki/",
"html_header_numbering": 2,
"html_header_numbering_sym": ".",
}],
}));
let w = &cfg.wikis[0];
assert_eq!(w.index, "home");
assert_eq!(w.diary_frequency, "weekly");
assert_eq!(w.diary_weekly_style, "date");
assert_eq!(w.diary_start_week_day, "sunday");
assert_eq!(w.diary_caption_level, 2);
assert_eq!(w.diary_sort, "asc");
assert_eq!(w.diary_header, "Journal");
assert_eq!(w.diary_months[4], "Mai");
assert_eq!(w.diary_months[11], "Dez");
assert_eq!(w.listsyms, "abcde");
assert_eq!(w.listsym_rejected, "");
assert_eq!(w.list_syms().rejected_glyph(), '✗');
assert!(!w.listsyms_propagate);
assert_eq!(w.list_margin, 2);
assert_eq!(w.links_space_char, "_");
assert!(w.auto_toc);
assert!(w.auto_generate_links);
assert!(w.auto_generate_tags); // int 1 → true
assert!(w.auto_diary_index);
assert_eq!(w.toc_header, "Table of Contents");
assert_eq!(w.toc_header_level, 2);
assert_eq!(w.links_header, "All Pages");
assert_eq!(w.links_header_level, 1); // unspecified → default
assert_eq!(w.tags_header, "Tag Index");
assert_eq!(w.tags_header_level, 3);
// custom_wiki2html keeps the literal command string (tilde-expansion is
// the converter's job, not ours); args + base_url round-trip verbatim.
assert_eq!(w.html.custom_wiki2html, "~/bin/my_wiki2html.sh");
assert_eq!(w.html.custom_wiki2html_args, "--flag");
assert_eq!(w.html.base_url, "https://example.com/wiki/");
assert_eq!(w.html.html_header_numbering, 2);
assert_eq!(w.html.html_header_numbering_sym, ".");
// The parsed keys drive a date-mode, Sunday-start diary calendar:
// a weekly note is the week-start date (YYYY-MM-DD), stepping ±7 days.
use nuwiki_core::date::{DiaryDate, DiaryPeriod};
let cal = w.diary_calendar();
let wed = DiaryPeriod::Day(DiaryDate::from_ymd(2026, 5, 27).unwrap());
assert_eq!(
cal.next(wed),
DiaryPeriod::Day(DiaryDate::from_ymd(2026, 5, 31).unwrap())
);
}
#[test]
fn markdown_wiki_derives_list_margin_zero_when_unset() {
// vimwiki sets list_margin = 0 for markdown wikis (vs -1 elsewhere) when
// the key is absent; an explicit value still wins.
let cfg = config_from_json(serde_json::json!({
"wikis": [{ "root": "/tmp/md", "syntax": "markdown" }],
}));
assert_eq!(cfg.wikis[0].list_margin, 0);
let vw = config_from_json(serde_json::json!({
"wikis": [{ "root": "/tmp/vw", "syntax": "vimwiki" }],
}));
assert_eq!(vw.wikis[0].list_margin, -1);
let explicit = config_from_json(serde_json::json!({
"wikis": [{ "root": "/tmp/md2", "syntax": "markdown", "list_margin": 4 }],
}));
assert_eq!(explicit.wikis[0].list_margin, 4);
}
#[test]
fn diary_caption_level_accepts_negative() {
// vimwiki allows diary_caption_level = -1 (min: -1); the field is i8 so it
// parses instead of failing deserialization.
let cfg = config_from_json(serde_json::json!({
"wikis": [{ "root": "/tmp/w", "diary_caption_level": -1 }],
}));
assert_eq!(cfg.wikis[0].diary_caption_level, -1);
}
#[test]
fn diary_calendar_defaults_to_iso_weeks() {
let cfg = WikiConfig::empty();
assert_eq!(cfg.diary_weekly_style, "iso");
assert_eq!(cfg.diary_start_week_day, "monday");
}
#[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");
}
+828
View File
@@ -0,0 +1,828 @@
//! 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", 1, 0, 0);
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", 1, 0, 0);
assert_eq!(out, "= Contents =\n");
}
#[test]
fn toc_link_format_1_drops_the_description() {
// vimwiki toc_link_format: 1 = `[[#anchor]]` (no `|title`); 0 = with title.
let items = vec![(1u8, "Top".into(), "top".into())];
let f0 = ops::build_toc_text(&items, "Contents", 1, 0, 0);
assert!(f0.contains("- [[#top|Top]]"), "format 0: {f0}");
let f1 = ops::build_toc_text(&items, "Contents", 1, 0, 1);
assert!(f1.contains("- [[#top]]"), "format 1: {f1}");
assert!(!f1.contains("|Top"), "format 1 has no description: {f1}");
}
#[test]
fn generated_links_caption_emits_first_heading() {
use std::collections::BTreeMap;
let pages = vec!["About".to_string(), "Notes".to_string()];
let mut caps = BTreeMap::new();
caps.insert("About".to_string(), "About Us".to_string());
// Notes has no caption → falls back to bare [[Notes]].
let out = ops::build_links_text(&pages, "Generated Links", 1, None, 0, Some(&caps));
assert!(out.contains("- [[About|About Us]]"), "captioned: {out}");
assert!(out.contains("- [[Notes]]"), "fallback: {out}");
// Without the caption map, both are bare.
let bare = ops::build_links_text(&pages, "Generated Links", 1, None, 0, None);
assert!(
bare.contains("- [[About]]") && !bare.contains("About Us"),
"bare: {bare}"
);
}
#[test]
fn links_rebuild_edit_only_acts_when_section_present() {
let pages = vec!["Home".to_string(), "About".to_string()];
let uri = Url::from_file_path("/w/Home.wiki").unwrap();
let without = "= Home =\nbody\n";
assert!(
ops::links_rebuild_edit(
without,
&parse(without),
&uri,
"Home",
&pages,
true,
"Generated Links",
1,
0,
None
)
.is_none(),
"must not insert a links section into a page that lacks one"
);
let with = "= Generated Links =\n- [[old]]\n";
assert!(ops::links_rebuild_edit(
with,
&parse(with),
&uri,
"Home",
&pages,
true,
"Generated Links",
1,
0,
None
)
.is_some());
}
#[test]
fn find_section_range_stops_at_same_level_sibling() {
// A non-level-1 generated section (e.g. a level-2 tags index) must not
// swallow the following same-level section when regenerated.
let src = "== Foo ==\n- a\n== Bar ==\n- b\n";
let ast = parse(src);
let (start, end) = ops::find_section_range(&ast, "Foo").expect("section found");
let bar_off = src.find("== Bar ==").unwrap();
assert_eq!(start.offset, 0);
assert!(
end.offset <= bar_off,
"section absorbed the sibling: end={} bar={}",
end.offset,
bar_off
);
}
#[test]
fn captions_honour_custom_header_and_level() {
// toc_header="Table of Contents", level 2 → `== Table of Contents ==`.
let out = ops::build_toc_text(&[], "Table of Contents", 2, 0, 0);
assert_eq!(out, "== Table of Contents ==\n");
// links_header at level 3.
let pages = vec!["A".to_string(), "B".to_string()];
let links = ops::build_links_text(&pages, "All Pages", 3, None, 0, None);
assert!(links.starts_with("=== All Pages ===\n"));
// level clamps to 1..=6.
assert!(ops::build_toc_text(&[], "X", 9, 0, 0).starts_with("====== X ======"));
assert!(ops::build_toc_text(&[], "X", 0, 0, 0).starts_with("= X ="));
}
#[test]
fn list_margin_indents_generated_bullets_not_headings() {
// vimwiki `list_margin` = leading spaces before each generated bullet.
// The heading stays at column 0; bullets get `margin` spaces (TOC keeps
// its per-depth nesting *after* the base margin).
let pages = vec!["A".to_string(), "B".to_string()];
let links = ops::build_links_text(&pages, "Generated Links", 1, None, 2, None);
assert!(links.contains("\n - [[A]]\n"), "2-space margin: {links:?}");
assert!(
links.starts_with("= Generated Links =\n"),
"heading unindented: {links:?}"
);
let toc = ops::build_toc_text(
&[
(1, "Top".into(), "top".into()),
(2, "Sub".into(), "sub".into()),
],
"Contents",
1,
2,
0,
);
// Top bullet: 2-space margin; Sub bullet: margin + one nesting level.
assert!(toc.contains("\n - [[#top|Top]]\n"), "top margin: {toc:?}");
assert!(toc.contains("\n - [[#sub|Sub]]\n"), "nested: {toc:?}");
}
// ===== 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", 1, Some("Home"), 0, None);
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", 1, None, 0, 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, "Contents", 1, 0, 0).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"));
// Anchor is the raw heading text (vimwiki scheme), matching the heading id
// the HTML renderer emits on export.
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, "Contents", 1, 0, 0).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, "Contents", 1, 0, 0).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, "Contents", 1, 0, 0).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, "Contents", 1, 0, 0).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,
"Generated Links",
1,
0,
None,
)
.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,
"Generated Links",
1,
0,
None,
)
.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,
"Generated Links",
1,
0,
None
)
.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}");
}
}
+220
View File
@@ -0,0 +1,220 @@
//! 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}");
}
}
+228
View File
@@ -0,0 +1,228 @@
//! 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());
}
+312
View File
@@ -0,0 +1,312 @@
//! 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);
}
+92 -61
View File
@@ -4,12 +4,41 @@
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. 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 ## Repository Structure
``` ```
nuwiki/ 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 ├── plugin/ # Universal Vim/Neovim entry point
│ └── nuwiki.vim │ └── nuwiki.vim
@@ -18,7 +47,7 @@ nuwiki/
│ ├── init.lua │ ├── init.lua
│ ├── config.lua │ ├── config.lua
│ ├── lsp.lua │ ├── lsp.lua
│ └── install.lua # Binary download logic │ └── install.lua # Binary download / build-from-source logic
├── autoload/ # Lazy-loaded VimL (Vim compat layer) ├── autoload/ # Lazy-loaded VimL (Vim compat layer)
│ └── nuwiki/ │ └── nuwiki/
@@ -59,27 +88,30 @@ nuwiki/
└── .gitea/ └── .gitea/
└── workflows/ └── workflows/
── ci.yaml # Editor (Vim/Neovim) harnesses on every push/PR ── ci.yaml # Lint, test, fmt check on every push/PR
└── release.yaml # Cross-compile + release on v* tag
``` ```
(The Rust server's build/release workflow lives in the separate ## Key Crates
[nuwiki-rs](https://code.gfran.co/gffranco/nuwiki-rs) repository.)
## Server Component 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.
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: 3. **nuwiki-ls**: Binary crate that starts the stdio LSP server. Very thin — just initializes the LSP server and runs it.
```bash
git clone https://code.gfran.co/gffranco/nuwiki-rs
cd nuwiki-rs
cargo build --release -p nuwiki-ls
```
## Build & Installation ## Build & Installation
### Prerequisites ### 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+) - For editor integration: Vim 9+ or Neovim 0.5+ with an LSP client (vim-lsp recommended for Vim, built-in for Neovim 0.11+)
- The `nuwiki-ls` binary is downloaded automatically; no Rust toolchain required unless building from source
### 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`.
### Installation via Plugin Managers ### Installation via Plugin Managers
@@ -111,34 +143,37 @@ call dein#add('gffranco/nuwiki', {
```bash ```bash
git clone https://code.gfran.co/gffranco/nuwiki ~/.vim/pack/gffranco/start/nuwiki git clone https://code.gfran.co/gffranco/nuwiki ~/.vim/pack/gffranco/start/nuwiki
cd ~/.vim/pack/gffranco/start/nuwiki cd ~/.vim/pack/gffranco/start/nuwiki
vim -e -s -c "source scripts/download_bin.vim" -c "q" cargo build --release -p nuwiki-ls
mkdir -p bin && ln -s ../target/release/nuwiki-ls bin/nuwiki-ls
``` ```
Plain Vim users also need an LSP client — vim-lsp is recommended. Plain Vim users also need an LSP client — vim-lsp is recommended.
## Development Workflow ## Development Workflow
### Testing (Editor Layer) ### Testing
```bash ```bash
# Run editor tests (shell + Lua harnesses in development/tests/) # Run all tests (workspace)
./development/tests/test-keymaps.sh cargo test --workspace
./development/tests/test-calendar.sh
# Run tests for a specific crate
cargo test -p nuwiki-core
cargo test -p nuwiki-lsp
cargo test -p nuwiki-ls
``` ```
For Rust-level tests, see the [nuwiki-rs](https://code.gfran.co/gffranco/nuwiki-rs) repository: ### Linting & Formatting
```bash ```bash
# In the nuwiki-rs repo # Check formatting
cargo test --workspace cargo fmt -- --check
# Run Clippy
cargo clippy --workspace -- -D warnings cargo clippy --workspace -- -D warnings
``` ```
### Running the LSP Server Manually (for debugging) ### Running the LSP Server Manually (for debugging)
Build from [nuwiki-rs](https://code.gfran.co/gffranco/nuwiki-rs) and run:
```bash ```bash
# Build and run the binary
cargo run -p nuwiki-ls -- --help cargo run -p nuwiki-ls -- --help
``` ```
@@ -175,48 +210,44 @@ This verifies:
## Important Notes ## Important Notes
- The Rust LSP server is in the separate [nuwiki-rs](https://code.gfran.co/gffranco/nuwiki-rs) repository. - The core library (`nuwiki-core`) is completely editor-agnostic and can be tested independently.
- This plugin repo contains only the editor client layer (VimL/Lua). - 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.
- Editor layers (VimL/Lua) contain zero logic — they are pure wiring only. - Editor layers (VimL/Lua) contain zero logic — they are pure wiring only.
- All syntax-specific code lives in the Rust LSP server's `nuwiki-core` crate. - 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).
## Getting Started ## Getting Started
1. Clone this plugin repository: 1. Clone the repository:
```bash ```bash
git clone https://code.gfran.co/gffranco/nuwiki git clone https://code.gfran.co/gffranco/nuwiki
cd nuwiki cd nuwiki
``` ```
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. 2. Build the LSP binary:
```bash
cargo build --release -p nuwiki-ls
```
3. Open a .wiki file and verify: 3. Set up a test wiki directory:
- Syntax highlighting works ```bash
- `:VimwikiIndex` opens the index page mkdir -p ~/test-wiki
- LSP features (go-to-definition, hover, completions) work via `:checkhealth nuwiki` echo "# Test Wiki" > ~/test-wiki/index.wiki
```
To modify the LSP server itself, clone the [nuwiki-rs](https://code.gfran.co/gffranco/nuwiki-rs) repository and build the binary from source. 4. Install the plugin in your Neovim/Vim configuration using your preferred plugin manager (see above examples).
5. 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`
## CI/CD ## CI/CD
This repo (editor client) uses Gitea Actions: The project uses Gitea Actions:
- CI (`.gitea/workflows/ci.yaml`): Runs editor tests on every push/PR. - 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.
The Rust LSP server lives in [nuwiki-rs](https://code.gfran.co/gffranco/nuwiki-rs) which handles:
- Release (`.gitea/workflows/release.yaml`): Triggers on `v*` tag — cross-compiles for Linux targets and creates a Gitea release with downloadable binaries.
### Releasing the plugin
This repo has no release workflow — the plugin is distributed by plugin
managers cloning the git repo, so a "release" is just a git tag. There is no
automated version stamping (the old `scripts/set-version.sh` moved to
nuwiki-rs with the Rust crates), so cut a plugin release by hand:
1. Bump `g:nuwiki_version` in `plugin/nuwiki.vim`.
2. Commit, then `git tag vX.Y.Z && git push --tags`.
The plugin and the `nuwiki-ls` server version independently; the binary is
fetched from nuwiki-rs's `latest` release at install time.
## License ## License
+46 -19
View File
@@ -6,12 +6,16 @@ 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 nuwiki is a vimwiki-compatible Vim/Neovim plugin backed by a Rust language
server. The editor layers are thin clients; all parsing, navigation, and server. The editor layers are thin clients; all parsing, navigation, and
editing logic lives in the [nuwiki-rs](https://code.gfran.co/gffranco/nuwiki-rs) editing logic lives in the Rust crates and is reached over LSP.
repository and is reached over LSP.
| Property | Value | | Property | Value |
|---|---| |---|---|
| License | Dual MIT / Apache-2.0 | | 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+ | | Min editors | Neovim 0.11+, Vim 9.1+ |
--- ---
@@ -24,21 +28,42 @@ repository and is reached over LSP.
Editor client (VimL / Lua) — pure wiring, no logic Editor client (VimL / Lua) — pure wiring, no logic
│ LSP over stdio │ LSP over stdio
nuwiki-ls (nuwiki-rs) — Rust LSP server: protocol bridge, executeCommand, nuwiki-lsp — protocol bridge, executeCommand, document store, config
document store, config, parser, renderer
nuwiki-core — lexer → parser → AST → renderer (no editor deps)
``` ```
> The Rust LSP server is in the separate [nuwiki-rs](https://code.gfran.co/gffranco/nuwiki-rs) ### Crate dependency rules
> 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 - The VimL and Lua layers contain no logic — every command body issues a
`workspace/executeCommand` or a built-in LSP request. `workspace/executeCommand` or a built-in LSP request.
### Repository layout ### 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) plugin/nuwiki.vim # universal entry point (detects Vim vs Neovim)
lua/nuwiki/ # Neovim layer: init, config, lsp, keymaps, commands, lua/nuwiki/ # Neovim layer: init, config, lsp, keymaps, commands,
# folding, install # folding, install
@@ -47,15 +72,12 @@ ftdetect/ ftplugin/ syntax/ # filetype detection, buffer setup, fallback HL
doc/nuwiki.txt # :help doc/nuwiki.txt # :help
scripts/download_bin.vim # plugin-runtime binary download (build hooks) scripts/download_bin.vim # plugin-runtime binary download (build hooks)
development/ # dev-only tooling + this document (not shipped) development/ # dev-only tooling + this document (not shipped)
.gitea/workflows/ # ci.yaml, release.yaml
``` ```
--- ---
## 2. nuwiki-core (nuwiki-rs) ## 2. nuwiki-core
> 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 ### AST
@@ -177,8 +199,9 @@ raw and external URLs are never diagnosed.
Pre-built binaries are published as Gitea release assets Pre-built binaries are published as Gitea release assets
(`nuwiki-ls-{version}-{target}.tar.gz`) and fetched at install time by (`nuwiki-ls-{version}-{target}.tar.gz`) and fetched at install time by
`lua/nuwiki/install.lua` (Neovim) or `scripts/download_bin.vim` (Vim build `lua/nuwiki/install.lua` (Neovim) or `scripts/download_bin.vim` (Vim build
hooks), installed to `{plugin_dir}/bin/nuwiki-ls`. The binary is downloaded hooks), installed to `{plugin_dir}/bin/nuwiki-ls`. Falls back to
from the [nuwiki-rs](https://code.gfran.co/gffranco/nuwiki-rs) releases. `cargo build --release` when download fails or
`g:nuwiki_build_from_source = 1`.
`plugin/nuwiki.vim` dispatches: Neovim → `require('nuwiki').setup()`; Vim → `plugin/nuwiki.vim` dispatches: Neovim → `require('nuwiki').setup()`; Vim →
`nuwiki#lsp#start()` (prefers `vim-lsp`, falls back to `coc.nvim`). On Neovim `nuwiki#lsp#start()` (prefers `vim-lsp`, falls back to `coc.nvim`). On Neovim
@@ -300,12 +323,16 @@ provides a no-LSP fallback):
| Job | Command | | 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) | | 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` (in [nuwiki-rs](https://code.gfran.co/gffranco/nuwiki-rs)) `.gitea/workflows/release.yaml` triggers on `v*` tags: cross-compiles the four
triggers on `v*` tags: cross-compiles the four Linux targets (gnu/musl × x86_64/aarch64) Linux targets (gnu/musl × x86_64/aarch64) via `cross`, packages `.tar.gz`, and
via `cross`, packages `.tar.gz`, and creates a Gitea release using `RELEASE_TOKEN`. 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.
A change is semver-breaking if it alters `nuwiki-core` AST nodes, the A change is semver-breaking if it alters `nuwiki-core` AST nodes, the
`SyntaxPlugin`/`Renderer` traits, the user-config schema, or removes an LSP `SyntaxPlugin`/`Renderer` traits, the user-config schema, or removes an LSP
+12 -26
View File
@@ -10,7 +10,7 @@
# Exports: # Exports:
# REPO_ROOT / DEV_DIR / WIKI_DIR — common paths # REPO_ROOT / DEV_DIR / WIKI_DIR — common paths
# log — prefixed status line # log — prefixed status line
# ensure_binary — verify nuwiki-ls exists (download if missing) # ensure_binary — build + symlink nuwiki-ls
# write_sample_wiki DIR — lay down the feature-showcase wiki # write_sample_wiki DIR — lay down the feature-showcase wiki
# reset_dirs DIR... — wipe + recreate owned state dirs # reset_dirs DIR... — wipe + recreate owned state dirs
# seed_wiki — (re)seed WIKI_DIR fresh each run; never # seed_wiki — (re)seed WIKI_DIR fresh each run; never
@@ -27,34 +27,20 @@ log() { printf '\033[1;34m[nuwiki-dev]\033[0m %s\n' "$*"; }
ensure_binary() { ensure_binary() {
local bin="$REPO_ROOT/bin/nuwiki-ls" local bin="$REPO_ROOT/bin/nuwiki-ls"
if [[ -x "$bin" ]]; then local built="$REPO_ROOT/target/release/nuwiki-ls"
log "binary ready: $bin"
return
fi
if [[ "${NUWIKI_DEV_SKIP_BUILD:-0}" == "1" ]]; then if [[ "${NUWIKI_DEV_SKIP_BUILD:-0}" == "1" ]]; then
log "NUWIKI_DEV_SKIP_BUILD=1 but $bin is missing" if [[ ! -x "$bin" ]]; then
return 1 log "NUWIKI_DEV_SKIP_BUILD=1 but $bin is missing — building anyway"
fi else
log "downloading nuwiki-ls…" log "NUWIKI_DEV_SKIP_BUILD=1 — using existing $bin"
vim -e -s -c "source $REPO_ROOT/scripts/download_bin.vim" -c "q" return
if [[ -x "$bin" ]]; then fi
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 fi
log "building nuwiki-ls (release)…"
cargo build --release -p nuwiki-ls --manifest-path "$REPO_ROOT/Cargo.toml"
mkdir -p "$REPO_ROOT/bin" mkdir -p "$REPO_ROOT/bin"
ln -sf "$rs_repo/target/release/nuwiki-ls" "$bin" ln -sf "$built" "$bin"
log "binary ready: $bin" log "binary ready: $bin (built $(date -r "$built" '+%Y-%m-%d %H:%M:%S'))"
} }
# write_sample_wiki DIR — lay down a fixture that exercises every vimwiki # write_sample_wiki DIR — lay down a fixture that exercises every vimwiki
+13 -11
View File
@@ -2,17 +2,25 @@
# #
# start-nvim.sh — launch Neovim with a minimal config that loads nuwiki. # start-nvim.sh — launch Neovim with a minimal config that loads nuwiki.
# #
# The plugin's install() downloads a pre-built release of nuwiki-ls for # Builds the language server (release mode), symlinks it into `bin/` so
# your platform from the nuwiki-rs repository. On first launch a missing # the plugin's default install path resolves, then spawns Neovim with
# binary is auto-downloaded via the bundled Vim script; set # `--clean` and a generated `init.lua` that:
# NUWIKI_DEV_SKIP_BUILD=1 to skip the download entirely. # * adds this repo to `runtimepath`
# * configures wiki_root to a scratch directory under
# $XDG_CACHE_HOME/nuwiki-dev/wiki
# * calls `require('nuwiki').setup({...})`
# #
# Usage: ./development/start-nvim.sh [extra args...] # Usage: ./development/start-nvim.sh [extra args...]
# ./development/start-nvim.sh # open the scratch wiki's index # ./development/start-nvim.sh # open the scratch wiki's index
# ./development/start-nvim.sh path/to/note.wiki # open a specific file # ./development/start-nvim.sh path/to/note.wiki # open a specific file
# NUWIKI_DEV_WIKI=/tmp/foo ./development/start-nvim.sh # 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_NO_SEED=1 ./development/start-nvim.sh # use WIKI_DIR as-is (no scratch seeding)
# NUWIKI_DEV_SKIP_BUILD=1 ./development/start-nvim.sh # skip the download step # 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.
# #
# Each launch starts clean: the scratch wiki is reseeded from a pristine # 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 # copy and the editor state (cache/data/config) is wiped, so stale edits
@@ -56,12 +64,6 @@ vim.g.nuwiki_diary_rel_path = 'diary'
require('nuwiki').setup({ require('nuwiki').setup({
wiki_root = '$WIKI_DIR', wiki_root = '$WIKI_DIR',
-- Exercise the per-wiki display settings via the global shorthand: these
-- fold into the single wiki, so :NuwikiTOC writes `== Contents ==` (level 2)
-- and HTML export numbers headings.
toc_header_level = 2,
html_header_numbering = 2,
html_header_numbering_sym = ' -',
}) })
-- Surface log lines (the LSP logs via window/logMessage). Without this -- Surface log lines (the LSP logs via window/logMessage). Without this
+35 -36
View File
@@ -22,12 +22,12 @@
# ./development/start-vim-coc.sh path/to/note.wiki # open a specific file # ./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_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_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 # skip the download # NUWIKI_DEV_SKIP_BUILD=1 ./development/start-vim-coc.sh # reuse the cached binary
# NUWIKI_DEV_COC_JUMP=edit ./development/start-vim-coc.sh # change coc.preferences.jumpCommand # NUWIKI_DEV_COC_JUMP=edit ./development/start-vim-coc.sh # change coc.preferences.jumpCommand
# NUWIKI_DEV_VIMWIKI=1 ./development/start-vim-coc.sh # drive nuwiki from a g:vimwiki_list config (drop-in)
# #
# The LSP binary is downloaded on every launch if missing, falling back to a # The release binary is rebuilt on every launch so source changes always reach
# cached version. Set NUWIKI_DEV_SKIP_BUILD=1 to skip the download. # 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.
# #
# Each launch starts clean: the scratch wiki is reseeded from a pristine # 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 # copy and the editor state (viminfo/swap/undo, coc data) is wiped, so stale
@@ -72,43 +72,42 @@ ensure_coc() {
write_coc_settings() { write_coc_settings() {
mkdir -p "$DEV_DIR" mkdir -p "$DEV_DIR"
# No `languageserver.nuwiki` block here on purpose: this dogfoods nuwiki's # Mirror what the vim-lsp client sends (autoload/nuwiki/lsp.vim s:settings()):
# *programmatic* coc registration (autoload/nuwiki/lsp.vim → coc#config). The # both `initializationOptions` (sent on initialize) and `settings.nuwiki`
# plugin registers the server itself from g:nuwiki_* (set in the vimrc), # (served via workspace/configuration). Without these the server falls back
# exactly the "no hand-written coc-settings.json" flow real users get. Only # to its own default wiki_root (~/vimwiki) instead of $WIKI_DIR, so it can't
# the jump-command preference (the <CR>-opens-a-tab reproducer) lives here. # resolve links (e.g. Notes shows as broken) or create-on-follow correctly.
cat > "$COC_SETTINGS" <<EOF cat > "$COC_SETTINGS" <<EOF
{ {
"coc.preferences.jumpCommand": "$COC_JUMP" "coc.preferences.jumpCommand": "$COC_JUMP",
"languageserver": {
"nuwiki": {
"command": "$REPO_ROOT/bin/nuwiki-ls",
"filetypes": ["vimwiki"],
"initializationOptions": {
"wiki_root": "$WIKI_DIR",
"file_extension": ".wiki",
"syntax": "vimwiki",
"log_level": "info",
"diagnostic": { "link_severity": "warn" }
},
"settings": {
"nuwiki": {
"wiki_root": "$WIKI_DIR",
"file_extension": ".wiki",
"syntax": "vimwiki",
"log_level": "info",
"diagnostic": { "link_severity": "warn" }
}
}
}
}
} }
EOF EOF
} }
write_vimrc() { write_vimrc() {
mkdir -p "$DEV_DIR" mkdir -p "$DEV_DIR"
# Wiki config block. Default: native g:nuwiki_* (single wiki). With
# NUWIKI_DEV_VIMWIKI=1: an upstream g:vimwiki_list config instead, to dogfood
# the g:vimwiki_* drop-in translation (the path real vimwiki migrants use).
local WIKI_CFG
if [[ "${NUWIKI_DEV_VIMWIKI:-0}" == "1" ]]; then
log "NUWIKI_DEV_VIMWIKI=1 — driving nuwiki from an upstream g:vimwiki_list config"
WIKI_CFG="\" Upstream vimwiki config (drop-in). nuwiki translates these.
let g:vimwiki_toc_header_level = 2
let g:vimwiki_html_header_numbering = 2
let g:vimwiki_html_header_numbering_sym = ' -'
let g:vimwiki_list = [{'path': '${WIKI_DIR}', 'path_html': '${DEV_DIR}/html', 'ext': '.wiki'}]"
else
WIKI_CFG="let g:nuwiki_wiki_root = '${WIKI_DIR}'
let g:nuwiki_file_extension = '.wiki'
let g:nuwiki_diary_rel_path = 'diary'
\" Display settings exercise the global shorthand: :NuwikiTOC writes
\" \`== Contents ==\` (level 2) and HTML export numbers headings.
let g:nuwiki_toc_header_level = 2
let g:nuwiki_html_header_numbering = 2
let g:nuwiki_html_header_numbering_sym = ' -'"
fi
cat > "$VIMRC" <<EOF cat > "$VIMRC" <<EOF
" start-vim-coc.sh: generated minimal config for nuwiki development (coc.nvim). " start-vim-coc.sh: generated minimal config for nuwiki development (coc.nvim).
" Regenerated on every launch; edit start-vim-coc.sh, not this file. " Regenerated on every launch; edit start-vim-coc.sh, not this file.
@@ -132,11 +131,11 @@ let g:coc_config_home = '${DEV_DIR}'
let g:coc_data_home = '${COC_DATA}' let g:coc_data_home = '${COC_DATA}'
let g:nuwiki_binary_path = '${REPO_ROOT}/bin/nuwiki-ls' let g:nuwiki_binary_path = '${REPO_ROOT}/bin/nuwiki-ls'
let g:nuwiki_wiki_root = '${WIKI_DIR}'
let g:nuwiki_file_extension = '.wiki'
let g:nuwiki_syntax = 'vimwiki' let g:nuwiki_syntax = 'vimwiki'
let g:nuwiki_diary_rel_path = 'diary'
let g:nuwiki_log_level = 'info' let g:nuwiki_log_level = 'info'
" nuwiki auto-registers itself with coc from these globals (no coc-settings.json
" languageserver block).
${WIKI_CFG}
filetype plugin indent on filetype plugin indent on
syntax enable syntax enable
+6 -12
View File
@@ -7,18 +7,18 @@
# the dev cache on first run so the LSP integration is exercisable # the dev cache on first run so the LSP integration is exercisable
# without polluting your real Vim install. # 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...] # Usage: ./development/start-vim.sh [extra args...]
# ./development/start-vim.sh # open the scratch wiki's index # ./development/start-vim.sh # open the scratch wiki's index
# ./development/start-vim.sh path/to/note.wiki # open a specific file # ./development/start-vim.sh path/to/note.wiki # open a specific file
# NUWIKI_DEV_WIKI=/tmp/foo ./development/start-vim.sh # 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_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_NO_SEED=1 ./development/start-vim.sh # use WIKI_DIR as-is (no scratch seeding)
# NUWIKI_DEV_SKIP_BUILD=1 ./development/start-vim.sh # skip the download step # 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.
# #
# Each launch starts clean: the scratch wiki is reseeded from a pristine # 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 # copy and the editor state (viminfo/swap/undo) is wiped, so stale edits
@@ -87,12 +87,6 @@ let g:nuwiki_wiki_root = '${WIKI_DIR}'
let g:nuwiki_file_extension = '.wiki' let g:nuwiki_file_extension = '.wiki'
let g:nuwiki_syntax = 'vimwiki' let g:nuwiki_syntax = 'vimwiki'
let g:nuwiki_log_level = 'info' let g:nuwiki_log_level = 'info'
" Exercise the per-wiki display settings via the global shorthand: these fold
" into the single wiki, so :NuwikiTOC writes `== Contents ==` (level 2) and
" HTML export numbers headings.
let g:nuwiki_toc_header_level = 2
let g:nuwiki_html_header_numbering = 2
let g:nuwiki_html_header_numbering_sym = ' -'
filetype plugin indent on filetype plugin indent on
syntax enable syntax enable
-18
View File
@@ -89,24 +89,6 @@ call s:record(winnr('$') == s:wins_before - 1, 'action.window_count_decremented'
call s:record(&filetype ==# 'vimwiki', 'action.sets_filetype', call s:record(&filetype ==# 'vimwiki', 'action.sets_filetype',
\ 'filetype=' . &filetype) \ 'filetype=' . &filetype)
" ===== calendar follows the current wiki (multi-wiki) =====
" Two wikis, each with its own diary entry. track_wiki() (driven by the
" BufEnter autocmd in real use) switches which wiki the calendar reads.
let s:wA = tempname() | call mkdir(s:wA . '/diary', 'p')
let s:wB = tempname() | call mkdir(s:wB . '/diary', 'p')
call writefile(['a'], s:wA . '/diary/2026-06-02.wiki')
call writefile(['b'], s:wB . '/diary/2026-06-10.wiki')
let g:nuwiki_wikis = [{'root': s:wA}, {'root': s:wB}]
call nuwiki#diary#track_wiki(s:wA . '/index.wiki')
call s:record(nuwiki#diary#calendar_sign(2, 6, 2026) ==# '*', 'track.wikiA_own_entry', '')
call s:record(nuwiki#diary#calendar_sign(10, 6, 2026) ==# '', 'track.wikiA_not_wikiB_entry', '')
call nuwiki#diary#track_wiki(s:wB . '/index.wiki')
call s:record(nuwiki#diary#calendar_sign(10, 6, 2026) ==# '*', 'track.wikiB_own_entry', '')
call s:record(nuwiki#diary#calendar_sign(2, 6, 2026) ==# '', 'track.wikiB_not_wikiA_entry', '')
unlet g:nuwiki_wikis
" ===== Wrap up ===== " ===== Wrap up =====
call add(s:results, '') call add(s:results, '')
+8 -29
View File
@@ -10,8 +10,8 @@
# * opting out with use_calendar = false. # * opting out with use_calendar = false.
# #
# A stub calendar-vim on the runtimepath provides :Calendar and seeds the # A stub calendar-vim on the runtimepath provides :Calendar and seeds the
# default hook names. The LSP binary is downloaded (setup() registers the # default hook names. The LSP binary is built (setup() registers the client);
# client); the calendar paths themselves don't need the server. # the calendar paths themselves don't need the server.
# #
# Exit: 0 when both runs are green, 1 otherwise. Skips (0) without nvim. # Exit: 0 when both runs are green, 1 otherwise. Skips (0) without nvim.
@@ -28,34 +28,13 @@ if ! command -v nvim >/dev/null 2>&1; then
exit 0 exit 0
fi fi
# Download the LSP binary from nuwiki-rs releases (falls back to cargo build). # setup() registers the LSP client, so build the binary like the other
# Neovim harnesses do (incremental, fast).
BIN="$REPO_ROOT/bin/nuwiki-ls" BIN="$REPO_ROOT/bin/nuwiki-ls"
log 'downloading nuwiki-ls…' log 'building nuwiki-ls (release, incremental)…'
if [[ ! -x "$BIN" ]]; then cargo build --release -p nuwiki-ls --manifest-path "$REPO_ROOT/Cargo.toml" >/dev/null
vim -e -s -c "source $REPO_ROOT/scripts/download_bin.vim" -c "q" >/dev/null 2>&1 || true mkdir -p "$REPO_ROOT/bin"
fi ln -sf "$REPO_ROOT/target/release/nuwiki-ls" "$BIN"
# 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) ---------------- # --- Scratch wiki with one seeded diary entry (2026-05-30) ----------------
WIKI="$TMP/wiki" WIKI="$TMP/wiki"
@@ -1,71 +0,0 @@
#!/usr/bin/env bash
#
# development/tests/test-coc-register-vim.sh — programmatic coc.nvim
# registration for the Vim client.
#
# nuwiki#lsp#start() registers the language server with coc via coc#config so
# users don't hand-maintain coc-settings.json. This harness stubs coc#config
# (a real autoload/coc.vim on the runtimepath, since Vim's exists('*coc#config')
# goes through the autoload mechanism) to record what nuwiki injects, then runs
# test-coc-register-vim.vim and asserts the payload.
#
# Exit code: 0 when every check passes, 1 on any FAIL or missing output.
# Skips (exit 0) when vim is not installed.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
log() { printf '\033[1;34m[coc-register]\033[0m %s\n' "$*"; }
if ! command -v vim >/dev/null 2>&1; then
log 'vim not installed — skipping'
exit 0
fi
TMP="$(mktemp -d -t nuwiki-coc-test-XXXXXX)"
trap 'rm -rf "$TMP"' EXIT
# Stub coc#config as a real autoload function so exists('*coc#config') resolves
# it. It records the last call into g:_recorded for the test to assert.
mkdir -p "$TMP/stub/autoload"
cat > "$TMP/stub/autoload/coc.vim" <<'EOF'
function! coc#config(section, value) abort
let g:_recorded = {'section': a:section, 'value': deepcopy(a:value)}
endfunction
EOF
OUT="$TMP/coc.out"
VIMRC="$TMP/vimrc"
cat > "$VIMRC" <<EOF
set nocompatible
" Stub dir first so its autoload/coc.vim is the one that resolves.
let &runtimepath = '$TMP/stub' . ',' . '$REPO_ROOT' . ',' . &runtimepath
" coc.nvim defines :CocConfig at startup; nuwiki uses that as the reliable
" 'coc is present' signal. Stub it so the coc branch is taken.
command! -nargs=0 CocConfig echo ''
EOF
log 'running Vim coc-registration harness…'
NUWIKI_COC_OUT="$OUT" \
timeout 30 vim -e -s -u "$VIMRC" \
-c "source $REPO_ROOT/development/tests/test-coc-register-vim.vim" \
</dev/null >"$TMP/vim.log" 2>&1 || true
if [[ ! -f "$OUT" ]]; then
echo 'coc-registration harness produced no output' >&2
echo '--- vim log ---' >&2
cat "$TMP/vim.log" >&2 || true
exit 1
fi
cat "$OUT"
PASSED="$(grep -c '^PASS ' "$OUT" || true)"
FAILED="$(grep -c '^FAIL ' "$OUT" || true)"
echo "SUMMARY: ${PASSED} passed, ${FAILED} failed"
if [[ "$FAILED" -ne 0 ]]; then
exit 1
fi
log 'coc registration OK ✓'
exit 0
@@ -1,42 +0,0 @@
" development/tests/test-coc-register-vim.vim — programmatic coc.nvim
" registration. Relies on a stub autoload/coc.vim (created by the wrapper) that
" records coc#config calls into g:_recorded. Drives nuwiki#lsp#start()'s coc
" branch and asserts the exact languageserver config nuwiki injects (command,
" filetypes, and the s:settings() payload with the global shorthand folded in).
" Writes PASS/FAIL to $NUWIKI_COC_OUT.
let s:out = []
function! s:check(desc, cond) abort
call add(s:out, (a:cond ? 'PASS ' : 'FAIL ') . a:desc)
endfunction
" Pretend coc's node service is already up so registration runs synchronously.
let g:coc_service_initialized = 1
let g:_recorded = {}
" Make sure the vim-lsp branch is NOT taken.
if exists('g:lsp_loaded') | unlet g:lsp_loaded | endif
" A readable, executable 'binary' so nuwiki#lsp#start() gets past its check.
let g:nuwiki_binary_path = executable('true') ? exepath('true') : '/usr/bin/true'
" Config: single-wiki shorthand + a global display setting that must fold into
" the wiki the server sees (the global shorthand).
let g:nuwiki_wiki_root = '/tmp/realwiki'
let g:nuwiki_toc_header_level = 2
call nuwiki#lsp#start()
let s:r = get(g:, '_recorded', {})
call s:check('coc#config was called', !empty(s:r))
call s:check('section is languageserver.nuwiki', get(s:r, 'section', '') ==# 'languageserver.nuwiki')
let s:v = get(s:r, 'value', {})
call s:check('command is the binary', get(s:v, 'command', '') ==# g:nuwiki_binary_path)
call s:check('filetypes = [vimwiki]', get(s:v, 'filetypes', []) == ['vimwiki'])
let s:io = get(s:v, 'initializationOptions', {})
call s:check('initOpts has wiki_root', get(s:io, 'wiki_root', '') ==# '/tmp/realwiki')
call s:check('global toc_header_level folded into payload', get(s:io, 'toc_header_level', 0) == 2)
call s:check('settings.nuwiki mirrors initOpts', get(get(s:v, 'settings', {}), 'nuwiki', {}) ==# s:io)
call writefile(s:out, $NUWIKI_COC_OUT)
qa!
@@ -1,87 +0,0 @@
-- development/tests/test-global-shorthand.lua — Neovim client global
-- shorthand: a per-wiki setting given once at the top level (setup() or
-- g:nuwiki_<key>) is folded into every wiki as a default, and a per-wiki
-- value overrides it. Mirror of the Vim test-vimwiki-compat checks.
--
-- Writes PASS/FAIL lines to $NUWIKI_GS_OUT; the wrapper fails on any FAIL.
local config = require('nuwiki.config')
local lsp = require('nuwiki.lsp')
local out = {}
local function check(desc, cond)
table.insert(out, (cond and 'PASS ' or 'FAIL ') .. desc)
end
-- ----- setup() top-level scalar folds into every wiki; per-wiki wins -----
config.apply({
toc_header_level = 2,
html_header_numbering = 2,
wikis = {
{ root = '/tmp/one', name = 'One' },
{ root = '/tmp/two', name = 'Two', toc_header_level = 3 },
},
})
local io1 = lsp.init_options()
check('w0 inherits global level', io1.wikis[1].toc_header_level == 2)
check('w0 inherits numbering', io1.wikis[1].html_header_numbering == 2)
check('w1 per-wiki override wins', io1.wikis[2].toc_header_level == 3)
check('w1 still inherits numbering', io1.wikis[2].html_header_numbering == 2)
-- The user's setup table must not be mutated by the fold.
check('no mutation of user wikis', config.user.wikis[1].toc_header_level == nil)
-- ----- built-in defaults are NOT folded (only explicit user values) -----
config.apply({
wikis = { { root = '/tmp/x', name = 'X' } },
})
local io2 = lsp.init_options()
check('default toc_header not folded', io2.wikis[1].toc_header == nil)
check('default level not folded', io2.wikis[1].toc_header_level == nil)
-- ----- g:nuwiki_<key> acts as a global too -----
config.apply({
wikis = { { root = '/tmp/y', name = 'Y' } },
})
vim.g.nuwiki_toc_header_level = 5
local io3 = lsp.init_options()
check('g:nuwiki_ global folds', io3.wikis[1].toc_header_level == 5)
vim.g.nuwiki_toc_header_level = nil
-- ----- upstream vim.g.vimwiki_list drop-in (the lazy.nvim vimwiki-swap case) --
config.apply({}) -- setup() with no native config
vim.g.vimwiki_list = {
{ path = '~/.vimwiki/personal', path_html = '~/public_html/personal', auto_export = 1 },
{ path = '~/.vimwiki/work' },
}
vim.g.vimwiki_toc_header_level = 2
local io4 = lsp.init_options()
check('vimwiki_list -> 2 wikis', io4.wikis ~= nil and #io4.wikis == 2)
check('path -> root', io4.wikis[1].root == '~/.vimwiki/personal')
check('path_html -> html_path', io4.wikis[1].html_path == '~/public_html/personal')
check('vimwiki global folds', io4.wikis[1].toc_header_level == 2)
check('w2 root', io4.wikis[2].root == '~/.vimwiki/work')
-- Buffer-side commands (<Leader>ww) resolve the same wikis.
check('wiki_list reads vimwiki_list', config.wiki_list()[1].root == '~/.vimwiki/personal')
-- Native g:nuwiki_wikis wins over g:vimwiki_list.
vim.g.nuwiki_wikis = { { root = '~/native', name = 'native' } }
local io5 = lsp.init_options()
check('native g:nuwiki_wikis wins', #io5.wikis == 1 and io5.wikis[1].root == '~/native')
vim.g.nuwiki_wikis = nil
vim.g.vimwiki_list = nil
vim.g.vimwiki_toc_header_level = nil
-- ----- setup({ wikis }) bridges the Lua config to VimL (g:nuwiki_wikis) -----
-- So buffer-side VimL helpers + the vimwiki-sync compat shim see the same
-- wikis as the LSP, even though setup() config lives only in Lua.
require('nuwiki').setup({ wikis = { { root = '/tmp/a', name = 'A' }, { root = '/tmp/b' } } })
check('setup() bridges to g:nuwiki_wikis',
vim.g.nuwiki_wikis ~= nil and #vim.g.nuwiki_wikis == 2)
check('bridged wiki root visible to VimL',
vim.g.nuwiki_wikis and vim.g.nuwiki_wikis[1].root == '/tmp/a')
vim.g.nuwiki_wikis = nil
local f = assert(io.open(os.getenv('NUWIKI_GS_OUT'), 'w'))
f:write(table.concat(out, '\n') .. '\n')
f:close()
vim.cmd('qa!')
@@ -1,54 +0,0 @@
#!/usr/bin/env bash
#
# development/tests/test-global-shorthand.sh — Neovim client global-shorthand
# folding (vimwiki-style per-wiki defaults). Runs test-global-shorthand.lua
# under headless Neovim; that script asserts the init_options payload folds a
# top-level / g:nuwiki_* setting into every wiki, lets a per-wiki value
# override it, and does NOT fold built-in defaults.
#
# Exit code: 0 when every check passes, 1 on any FAIL or missing output.
# Skips (exit 0) when nvim is not installed.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
log() { printf '\033[1;34m[global-shorthand]\033[0m %s\n' "$*"; }
if ! command -v nvim >/dev/null 2>&1; then
log 'nvim not installed — skipping'
exit 0
fi
TMP="$(mktemp -d -t nuwiki-gs-test-XXXXXX)"
trap 'rm -rf "$TMP"' EXIT
OUT="$TMP/gs.out"
INIT="$TMP/init.lua"
cat > "$INIT" <<EOF
vim.opt.runtimepath:prepend('$REPO_ROOT')
EOF
log 'running Neovim global-shorthand harness…'
NUWIKI_GS_OUT="$OUT" \
timeout 30 nvim --clean -u "$INIT" --headless \
-c "luafile $REPO_ROOT/development/tests/test-global-shorthand.lua" \
>"$TMP/nvim.log" 2>&1 || true
if [[ ! -f "$OUT" ]]; then
echo 'global-shorthand harness produced no output' >&2
echo '--- nvim log ---' >&2
cat "$TMP/nvim.log" >&2 || true
exit 1
fi
cat "$OUT"
PASSED="$(grep -c '^PASS ' "$OUT" || true)"
FAILED="$(grep -c '^FAIL ' "$OUT" || true)"
echo "SUMMARY: ${PASSED} passed, ${FAILED} failed"
if [[ "$FAILED" -ne 0 ]]; then
exit 1
fi
log 'global shorthand OK ✓'
exit 0
+9 -28
View File
@@ -5,7 +5,7 @@
# #
# The Lua harness needs a real LSP attachment (most keymaps dispatch # The Lua harness needs a real LSP attachment (most keymaps dispatch
# `workspace/executeCommand`), so we: # `workspace/executeCommand`), so we:
# 1. download `bin/nuwiki-ls` from the nuwiki-rs releases, # 1. ensure `bin/nuwiki-ls` is built (release),
# 2. spin up a scratch wiki under a temp dir, # 2. spin up a scratch wiki under a temp dir,
# 3. invoke `nvim --headless` with a minimal init that loads the # 3. invoke `nvim --headless` with a minimal init that loads the
# plugin + calls `setup()`, then sources the harness Lua. # plugin + calls `setup()`, then sources the harness Lua.
@@ -20,34 +20,15 @@ trap 'rm -rf "$TMP"' EXIT
log() { printf '\033[1;34m[keymap-test]\033[0m %s\n' "$*"; } log() { printf '\033[1;34m[keymap-test]\033[0m %s\n' "$*"; }
# Download the LSP binary from nuwiki-rs releases (falls back to cargo build). # 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.
BIN="$REPO_ROOT/bin/nuwiki-ls" BIN="$REPO_ROOT/bin/nuwiki-ls"
log 'downloading nuwiki-ls…' log 'building nuwiki-ls (release, incremental)…'
if [[ ! -x "$BIN" ]]; then cargo build --release -p nuwiki-ls --manifest-path "$REPO_ROOT/Cargo.toml" >/dev/null
vim -e -s -c "source $REPO_ROOT/scripts/download_bin.vim" -c "q" >/dev/null 2>&1 || true mkdir -p "$REPO_ROOT/bin"
fi ln -sf "$REPO_ROOT/target/release/nuwiki-ls" "$BIN"
# 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" mkdir -p "$TMP/wiki"
echo "= seed =" > "$TMP/wiki/index.wiki" echo "= seed =" > "$TMP/wiki/index.wiki"
@@ -1,59 +0,0 @@
#!/usr/bin/env bash
#
# development/tests/test-vimwiki-compat-vim.sh — upstream-vimwiki config
# drop-in compatibility for the Vim client.
#
# Runs test-vimwiki-compat-vim.vim under headless Vim; that script sets an
# upstream `g:vimwiki_list` + `g:vimwiki_*` config and asserts
# nuwiki#lsp#settings() translates it into the server's schema (per-wiki key
# remap + global scalar settings folded into each wiki). Also checks that
# nuwiki-native config takes precedence.
#
# Exit code: 0 when every check passes, 1 on any FAIL or missing output.
# Skips (exit 0) when vim is not installed.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
log() { printf '\033[1;34m[vimwiki-compat]\033[0m %s\n' "$*"; }
if ! command -v vim >/dev/null 2>&1; then
log 'vim not installed — skipping'
exit 0
fi
TMP="$(mktemp -d -t nuwiki-vwc-test-XXXXXX)"
trap 'rm -rf "$TMP"' EXIT
OUT="$TMP/vwc.out"
VIMRC="$TMP/vimrc"
cat > "$VIMRC" <<EOF
set nocompatible
let &runtimepath = '$REPO_ROOT' . ',' . &runtimepath
EOF
log 'running Vim vimwiki-compat harness…'
NUWIKI_VWC_OUT="$OUT" \
timeout 30 vim -e -s -u "$VIMRC" \
-c "source $REPO_ROOT/development/tests/test-vimwiki-compat-vim.vim" \
</dev/null >"$TMP/vim.log" 2>&1 || true
if [[ ! -f "$OUT" ]]; then
echo 'vimwiki-compat harness produced no output' >&2
echo '--- vim log ---' >&2
cat "$TMP/vim.log" >&2 || true
exit 1
fi
cat "$OUT"
PASSED="$(grep -c '^PASS ' "$OUT" || true)"
FAILED="$(grep -c '^FAIL ' "$OUT" || true)"
echo "SUMMARY: ${PASSED} passed, ${FAILED} failed"
if [[ "$FAILED" -ne 0 ]]; then
exit 1
fi
log 'vimwiki config compat OK ✓'
exit 0
@@ -1,86 +0,0 @@
" development/tests/test-vimwiki-compat-vim.vim — upstream-vimwiki config
" drop-in compatibility for the Vim client.
"
" Sourced by test-vimwiki-compat-vim.sh under headless Vim. Sets an upstream
" `g:vimwiki_list` + `g:vimwiki_*` config and asserts nuwiki#lsp#settings()
" translates it into the server's schema. Writes PASS/FAIL to
" $NUWIKI_VWC_OUT; the wrapper fails on any FAIL.
let s:out = []
function! s:check(desc, cond) abort
call add(s:out, (a:cond ? 'PASS ' : 'FAIL ') . a:desc)
endfunction
" ----- upstream vimwiki config (no nuwiki-native vars set) -----
let g:vimwiki_toc_header_level = 2
let g:vimwiki_html_header_numbering = 2
let g:vimwiki_html_header_numbering_sym = ' -'
let s:personal = {}
let s:personal.path = '~/.vimwiki/personal_wiki'
let s:personal.path_html = '~/public_html/personal_wiki'
let s:personal.template_default = 'default'
let s:personal.template_ext = '.tpl'
let s:personal.auto_export = 1
let s:ifood = {}
let s:ifood.path = '~/.vimwiki/ifood_wiki'
let g:vimwiki_list = [s:personal, s:ifood]
let s:cfg = nuwiki#lsp#settings()
call s:check('has wikis list', has_key(s:cfg, 'wikis'))
call s:check('two wikis', has_key(s:cfg, 'wikis') && len(s:cfg.wikis) == 2)
let s:w0 = get(s:cfg, 'wikis', [{}])[0]
let s:w1 = get(s:cfg, 'wikis', [{},{}])[1]
call s:check('w0 path->root', get(s:w0, 'root', '') ==# '~/.vimwiki/personal_wiki')
call s:check('w0 path_html->html_path', get(s:w0, 'html_path', '') ==# '~/public_html/personal_wiki')
call s:check('w0 template_default', get(s:w0, 'template_default', '') ==# 'default')
call s:check('w0 auto_export', get(s:w0, 'auto_export', 0) == 1)
call s:check('w0 toc_header_level=2', get(s:w0, 'toc_header_level', 0) == 2)
call s:check('w0 html_numbering=2', get(s:w0, 'html_header_numbering', 0) == 2)
call s:check('w0 numbering_sym', get(s:w0, 'html_header_numbering_sym', '') ==# ' -')
call s:check('w1 root', get(s:w1, 'root', '') ==# '~/.vimwiki/ifood_wiki')
call s:check('w1 globals applied', get(s:w1, 'toc_header_level', 0) == 2)
" The buffer-side commands (e.g. <Leader>ww) must resolve the SAME wikis from
" g:vimwiki_list — not just the LSP payload. Regression guard for "<Leader>ww
" opens an empty ~/vimwiki".
let s:wl = nuwiki#commands#wiki_list()
call s:check('wiki_list sees vimwiki config', len(s:wl) == 2)
call s:check('wiki_list w0 root', len(s:wl) >= 1 && s:wl[0].root ==# expand('~/.vimwiki/personal_wiki'))
call s:check('wiki_list w1 root', len(s:wl) >= 2 && s:wl[1].root ==# expand('~/.vimwiki/ifood_wiki'))
" ----- nuwiki-native config wins over vimwiki -----
let g:nuwiki_wikis = [{'root': '~/native', 'name': 'native'}]
let s:cfg2 = nuwiki#lsp#settings()
call s:check('native g:nuwiki_wikis wins',
\ len(get(s:cfg2, 'wikis', [])) == 1
\ && get(s:cfg2.wikis[0], 'root', '') ==# '~/native')
unlet g:nuwiki_wikis
" ----- nuwiki-native scalar overrides the vimwiki global -----
let g:vimwiki_list = []
let g:nuwiki_toc_header_level = 4
let s:cfg3 = nuwiki#lsp#settings()
" No wikis list now (vimwiki_list empty) → scalar folded into top-level.
call s:check('native scalar override', get(s:cfg3, 'toc_header_level', 0) == 4)
unlet g:nuwiki_toc_header_level
" ----- global shorthand on native g:nuwiki_wikis (vimwiki-style) -----
" A global default applies to every wiki; a per-wiki value overrides it.
let g:nuwiki_toc_header_level = 2
let g:nuwiki_html_header_numbering = 2
let g:nuwiki_wikis = [
\ {'root': '~/w1', 'name': 'One'},
\ {'root': '~/w2', 'name': 'Two', 'toc_header_level': 3},
\ ]
let s:cfg4 = nuwiki#lsp#settings()
let s:g0 = s:cfg4.wikis[0]
let s:g1 = s:cfg4.wikis[1]
call s:check('global folds into wiki 0', get(s:g0, 'toc_header_level', 0) == 2)
call s:check('global numbering folds', get(s:g0, 'html_header_numbering', 0) == 2)
call s:check('per-wiki override wins', get(s:g1, 'toc_header_level', 0) == 3)
call s:check('global still folds elsewhere', get(s:g1, 'html_header_numbering', 0) == 2)
" The user's original dict must not be mutated by the fold.
call s:check('no mutation of g:nuwiki_wikis', !has_key(g:nuwiki_wikis[0], 'toc_header_level'))
call writefile(s:out, $NUWIKI_VWC_OUT)
+26 -20
View File
@@ -50,11 +50,13 @@ Highlights:
============================================================================== ==============================================================================
2. REQUIREMENTS *nuwiki-requirements* 2. REQUIREMENTS *nuwiki-requirements*
* Neovim 0.11+ (preferred path) * Neovim 0.11+ (preferred path)
* Vim 9.1+ with one of: * Vim 9.1+ with one of:
- vim-lsp (prabirshrestha/vim-lsp) - vim-lsp (prabirshrestha/vim-lsp)
- coc.nvim (neoclide/coc.nvim) - coc.nvim (neoclide/coc.nvim)
* `curl` and `tar` (for the binary download path) * `curl` and `tar` (only for the binary download path; not needed if
you build from source)
* `cargo` and a Rust toolchain (only if you opt into source builds)
============================================================================== ==============================================================================
3. INSTALLATION *nuwiki-installation* 3. INSTALLATION *nuwiki-installation*
@@ -81,8 +83,16 @@ lazy.nvim: >
\ 'build': 'vim -e -s -c "source scripts/download_bin.vim" -c "q"', \ 'build': 'vim -e -s -c "source scripts/download_bin.vim" -c "q"',
\ }) \ })
<Note: the LSP binary is downloaded automatically. No cargo or Rust <To force a build from source (skip the download path), set: >
toolchain is required.==============================================================================
let g:nuwiki_build_from_source = 1
<in Vim before re-running the build hook, or in Neovim: >
vim.g.nuwiki_build_from_source = 1
require('nuwiki').install()
<==============================================================================
4. CONFIGURATION *nuwiki-config* 4. CONFIGURATION *nuwiki-config*
Neovim users call `setup()` (omit the block entirely to take every Neovim users call `setup()` (omit the block entirely to take every
@@ -370,10 +380,12 @@ Vim-specific globals ~
middle-click `:badd`s the link target, etc.). Mirrors middle-click `:badd`s the link target, etc.). Mirrors
`mappings.mouse = true` in Neovim. `mappings.mouse = true` in Neovim.
*g:nuwiki_no_calendar* *g:nuwiki_build_from_source*
`g:nuwiki_no_calendar` 0 `g:nuwiki_build_from_source` 0
When 1, the install step always builds the server with `cargo` and
skips the release-asset download.
*g:nuwiki_binary_path* *g:nuwiki_binary_path*
`g:nuwiki_binary_path` `g:nuwiki_binary_path`
Override the auto-discovered server binary path. If set and the Override the auto-discovered server binary path. If set and the
file is readable, it overrides the `{plugin}/bin/nuwiki-ls` default. file is readable, it overrides the `{plugin}/bin/nuwiki-ls` default.
@@ -513,15 +525,11 @@ Page generation ~
*:NuwikiTOC* *:NuwikiTOC*
:NuwikiTOC :NuwikiTOC
Generate or refresh the table of contents on the current page. A new TOC Generate or refresh the table of contents on the current page.
is inserted at the cursor line; an existing one is refreshed in place.
*:NuwikiGenerateLinks* *:NuwikiGenerateLinks*
:NuwikiGenerateLinks [path] :NuwikiGenerateLinks
Insert a flat list of every page in the wiki. With a `path` argument, the Insert a flat list of every page in the wiki under the cursor.
list is scoped to that subtree (a wiki-root-relative directory, or a glob
pattern containing `*`/`?`). A new section is inserted at the cursor line;
an existing one is refreshed in place.
*:NuwikiCheckLinks* *:NuwikiCheckLinks*
:[range]NuwikiCheckLinks :[range]NuwikiCheckLinks
@@ -547,9 +555,7 @@ Tags ~
*:NuwikiGenerateTagLinks* *:NuwikiGenerateTagLinks*
:NuwikiGenerateTagLinks [tag] :NuwikiGenerateTagLinks [tag]
Insert a section linking every page that has `<tag>`. With no Insert a section linking every page that has `<tag>`. With no
argument, generates a section per tag found in the workspace. A new argument, generates a section per tag found in the workspace.
section is inserted at the cursor line; an existing one is refreshed in
place.
*:NuwikiRebuildTags* *:NuwikiRebuildTags*
:NuwikiRebuildTags[!] :NuwikiRebuildTags[!]
-10
View File
@@ -128,16 +128,6 @@ correct as-is; the deferral is a deliberate risk/reward call.
## Notes ## Notes
- **Upstream `g:vimwiki_list` config (Vim & Neovim).** When you haven't
configured nuwiki natively, both clients read your upstream `g:vimwiki_list` +
`g:vimwiki_*` globals and translate them into nuwiki's schema (per-wiki
`path`/`path_html`/`template_*`/`auto_export`, plus globals `toc_header`,
`toc_header_level`, `html_header_numbering`, `html_header_numbering_sym`,
`links_space_char`, `list_margin`). `g:nuwiki_*` / `setup()` config always
wins. Globals nuwiki doesn't model (e.g. `automatic_nested_syntaxes`) are
ignored — see the divergence list above. On Neovim this means swapping the
`vimwiki` plugin for nuwiki under lazy.nvim works with your existing
`vim.g.vimwiki_list` untouched (call `require('nuwiki').setup()` with no args).
- **Third-party plugin compatibility.** A shim at `autoload/vimwiki/vars.vim` - **Third-party plugin compatibility.** A shim at `autoload/vimwiki/vars.vim`
exposes the subset of `vimwiki#vars#get_wikilocal` that plugins like exposes the subset of `vimwiki#vars#get_wikilocal` that plugins like
vimwiki-sync and vim-zettel call (`path`, `ext`/`extension`, `syntax`, vimwiki-sync and vim-zettel call (`path`, `ext`/`extension`, `syntax`,
+8 -12
View File
@@ -513,20 +513,16 @@ end
-- ===== Generation + workspace ===== -- ===== Generation + workspace =====
M.toc_generate = function() M.toc_generate = function() exec('nuwiki.toc.generate', uri_args()) end
-- Send the cursor line (0-based) so a fresh TOC is inserted there.
local line = vim.api.nvim_win_get_cursor(0)[1] - 1
exec('nuwiki.toc.generate', { { uri = buf_uri(), line = line } })
end
-- `:VimwikiGenerateLinks [rel_path]` — optional path scopes the links to a -- `:VimwikiGenerateLinks [rel_path]` — optional path scopes the links to a
-- subtree via the server's generateForPath; no arg = every page. -- subtree via the server's generateForPath; no arg = every page.
M.links_generate = function(path) M.links_generate = function(path)
-- Cursor line (0-based) so a fresh section is inserted there.
local line = vim.api.nvim_win_get_cursor(0)[1] - 1
if path and path ~= '' then if path and path ~= '' then
exec('nuwiki.links.generateForPath', { { uri = buf_uri(), path = path, line = line } }) local args = uri_args()[1]
args.path = path
exec('nuwiki.links.generateForPath', { args })
else else
exec('nuwiki.links.generate', { { uri = buf_uri(), line = line } }) exec('nuwiki.links.generate', uri_args())
end end
end end
@@ -606,8 +602,7 @@ function M.tags_search(query)
end end
function M.tags_generate_links(tag) function M.tags_generate_links(tag)
-- Cursor line (0-based) so a fresh section is inserted there. local args = { uri = buf_uri() }
local args = { uri = buf_uri(), line = vim.api.nvim_win_get_cursor(0)[1] - 1 }
if tag and tag ~= '' then if tag and tag ~= '' then
args.tag = tag args.tag = tag
end end
@@ -816,7 +811,8 @@ end
-- the cursor visually mid-cell after columns expand. For insert-mode -- the cursor visually mid-cell after columns expand. For insert-mode
-- editing we replicate the alignment logic locally so we can realign -- editing we replicate the alignment logic locally so we can realign
-- *before* placing the cursor, all in a single `vim.schedule` tick. -- *before* placing the cursor, all in a single `vim.schedule` tick.
-- The algorithm matches the LSP server's implementation in nuwiki-rs. -- The algorithm matches `crates/nuwiki-lsp/src/commands.rs::ops::
-- render_aligned_table`.
local function is_table_row(line) local function is_table_row(line)
return line ~= nil and line:match('^%s*|.*|%s*$') ~= nil return line ~= nil and line:match('^%s*|.*|%s*$') ~= nil
+10 -126
View File
@@ -35,19 +35,10 @@ M.defaults = {
-- custom_wiki2html, custom_wiki2html_args, base_url, -- custom_wiki2html, custom_wiki2html_args, base_url,
-- toc_header, toc_header_level, links_header, links_header_level, -- toc_header, toc_header_level, links_header, links_header_level,
-- tags_header, tags_header_level, -- tags_header, tags_header_level,
-- html_header_numbering, html_header_numbering_sym,
-- listsyms, listsym_rejected, listsyms_propagate, list_margin, -- listsyms, listsym_rejected, listsyms_propagate, list_margin,
-- links_space_char -- links_space_char
wikis = nil, wikis = nil,
-- TOC defaults (per-wiki, also accepted as top-level for single-wiki setups)
toc_header = 'Contents',
toc_header_level = 1,
-- HTML export defaults (per-wiki, also accepted as top-level for single-wiki setups)
html_header_numbering = 0,
html_header_numbering_sym = '',
-- Per-buffer glue. Each subgroup mirrors vimwiki's -- Per-buffer glue. Each subgroup mirrors vimwiki's
-- `g:vimwiki_key_mappings` shape and can be flipped off -- `g:vimwiki_key_mappings` shape and can be flipped off
-- independently to suppress that group of keymaps. -- independently to suppress that group of keymaps.
@@ -103,126 +94,19 @@ M.defaults = {
M.options = vim.deepcopy(M.defaults) M.options = vim.deepcopy(M.defaults)
-- The raw table the user passed to setup(), kept separate from the
-- defaults-merged `options`. Used to tell an explicitly-set value apart from
-- a built-in default — e.g. so the LSP client only treats a per-wiki key as a
-- global default when the user actually set it.
M.user = {}
function M.apply(user) function M.apply(user)
M.user = user or {}
M.options = vim.tbl_deep_extend('force', M.defaults, user or {}) M.options = vim.tbl_deep_extend('force', M.defaults, user or {})
end end
-- ===== Upstream vimwiki config compatibility (drop-in) =====
--
-- Single source of truth for the configured wikis, shared by the LSP payload
-- (lsp.lua) and the buffer-side commands. Mirrors autoload/nuwiki/config.vim so
-- a user who keeps their original vim.g.vimwiki_list + g:vimwiki_* config (e.g.
-- swapping the vimwiki plugin for nuwiki under lazy.nvim) needs no rewrite.
-- Native config (setup() wikis / g:nuwiki_*) always wins.
-- Upstream vimwiki per-wiki dict key -> nuwiki/server key.
local VIMWIKI_WIKI_MAP = {
path = 'root',
path_html = 'html_path',
template_path = 'template_path',
template_default = 'template_default',
template_ext = 'template_ext',
css_name = 'css_name',
auto_export = 'auto_export',
auto_toc = 'auto_toc',
syntax = 'syntax',
ext = 'file_extension',
index = 'index',
diary_rel_path = 'diary_rel_path',
diary_index = 'diary_index',
name = 'name',
}
-- Per-wiki settings that double as wiki-wide global defaults.
local SCALAR_GLOBAL_KEYS = {
'toc_header', 'toc_header_level', 'toc_link_format',
'links_header', 'links_header_level',
'tags_header', 'tags_header_level',
'html_header_numbering', 'html_header_numbering_sym',
'links_space_char', 'list_margin',
'listsyms', 'listsym_rejected',
'auto_export', 'auto_toc',
'auto_generate_links', 'auto_generate_tags', 'auto_diary_index',
}
-- Resolve the global scalar defaults applied to every wiki. Priority (low to
-- high): g:vimwiki_<key>, g:nuwiki_<key>, explicit setup() value. Built-in
-- defaults are excluded (reads M.user, not M.options) so they don't get folded
-- onto every wiki and override per-wiki values.
function M.scalar_globals()
local g = {}
for _, key in ipairs(SCALAR_GLOBAL_KEYS) do
if vim.g['vimwiki_' .. key] ~= nil then
g[key] = vim.g['vimwiki_' .. key]
end
if vim.g['nuwiki_' .. key] ~= nil then
g[key] = vim.g['nuwiki_' .. key]
end
if M.user[key] ~= nil then
g[key] = M.user[key]
end
end
return g
end
-- Translate vim.g.vimwiki_list into nuwiki's wiki list (server key names),
-- folding the global scalars into each entry. Returns {} when unset/malformed.
local function wikis_from_vimwiki(globals)
local list = vim.g.vimwiki_list
if type(list) ~= 'table' or vim.tbl_isempty(list) then
return {}
end
local out = {}
for _, vw in ipairs(list) do
if type(vw) == 'table' then
local w = vim.tbl_extend('force', {}, globals)
for src, dst in pairs(VIMWIKI_WIKI_MAP) do
if vw[src] ~= nil then
w[dst] = vw[src]
end
end
if w.root ~= nil then
table.insert(out, w)
end
end
end
return out
end
-- The configured wikis as normalized dicts (nuwiki/server key names), with the
-- global scalars folded in (per-wiki value wins). Priority:
-- 1. setup() opts.wikis
-- 2. g:nuwiki_wikis
-- 3. g:vimwiki_list (upstream drop-in)
-- Returns {} when none is set — callers fall back to the single-wiki shorthand.
function M.wikis()
local globals = M.scalar_globals()
local native = M.options.wikis or vim.g.nuwiki_wikis
if type(native) == 'table' and #native > 0 then
local out = {}
for i, w in ipairs(native) do
out[i] = vim.tbl_extend('force', globals, w)
end
return out
end
return wikis_from_vimwiki(globals)
end
-- Resolve the config for wiki #n (0-based) without touching the LSP. -- Resolve the config for wiki #n (0-based) without touching the LSP.
-- Returns { name, root, ext, idx, diary_rel, diary_idx, space }. -- Priority: setup() opts.wikis → g:nuwiki_wikis (VimL) → scalar opts/g: vars.
-- Returns { name, root, ext, idx, diary_rel, diary_idx }.
function M.wiki_cfg(n) function M.wiki_cfg(n)
local wikis = M.wikis() local opts = M.options
local w = wikis[(n or 0) + 1] local wikis = opts.wikis or vim.g.nuwiki_wikis or nil
local root = (w and w.root) or M.options.wiki_root or vim.g.nuwiki_wiki_root or '~/vimwiki' local w = wikis and wikis[(n or 0) + 1] or nil
local ext = (w and w.file_extension) or M.options.file_extension local root = (w and w.root) or opts.wiki_root or vim.g.nuwiki_wiki_root or '~/vimwiki'
or vim.g.nuwiki_file_extension or '.wiki' local ext = (w and w.file_extension) or opts.file_extension or vim.g.nuwiki_file_extension or '.wiki'
if not ext:match('^%.') then ext = '.' .. ext end if not ext:match('^%.') then ext = '.' .. ext end
return { return {
name = (w and w.name) or vim.fn.fnamemodify(vim.fn.expand(root), ':t'), name = (w and w.name) or vim.fn.fnamemodify(vim.fn.expand(root), ':t'),
@@ -231,7 +115,7 @@ function M.wiki_cfg(n)
idx = (w and w.index) or 'index', idx = (w and w.index) or 'index',
diary_rel = (w and w.diary_rel_path) or vim.g.nuwiki_diary_rel_path or 'diary', diary_rel = (w and w.diary_rel_path) or vim.g.nuwiki_diary_rel_path or 'diary',
diary_idx = (w and w.diary_index) or 'diary', diary_idx = (w and w.diary_index) or 'diary',
space = (w and w.links_space_char) or M.options.links_space_char space = (w and w.links_space_char) or opts.links_space_char
or vim.g.nuwiki_links_space_char or ' ', or vim.g.nuwiki_links_space_char or ' ',
} }
end end
@@ -239,9 +123,9 @@ end
-- The full list of configured wikis as wiki_cfg dicts. Falls back to a single -- The full list of configured wikis as wiki_cfg dicts. Falls back to a single
-- entry built from the scalar wiki_root config when no list is configured. -- entry built from the scalar wiki_root config when no list is configured.
function M.wiki_list() function M.wiki_list()
local wikis = M.wikis() local wikis = M.options.wikis or vim.g.nuwiki_wikis or nil
local list = {} local list = {}
if #wikis > 0 then if type(wikis) == 'table' and #wikis > 0 then
for i = 1, #wikis do for i = 1, #wikis do
list[i] = M.wiki_cfg(i - 1) list[i] = M.wiki_cfg(i - 1)
end end
+1 -1
View File
@@ -1,6 +1,6 @@
-- lua/nuwiki/folding.lua — `foldexpr` fallback when the LSP isn't -- lua/nuwiki/folding.lua — `foldexpr` fallback when the LSP isn't
-- attached. Same heading-block model as the server-side `foldingRange` -- attached. Same heading-block model as the server-side `foldingRange`
-- provider in the nuwiki-rs LSP server, but driven from a -- provider in `crates/nuwiki-lsp/src/folding.rs`, but driven from a
-- pure regex over `getline()` so it works without a running server. -- pure regex over `getline()` so it works without a running server.
-- --
-- Activated by `setlocal foldmethod=expr foldexpr=v:lua.require'nuwiki.folding'.expr()` -- Activated by `setlocal foldmethod=expr foldexpr=v:lua.require'nuwiki.folding'.expr()`
-11
View File
@@ -93,17 +93,6 @@ end
function M.setup(opts) function M.setup(opts)
config.apply(opts or {}) config.apply(opts or {})
-- Bridge a Lua-only `setup({ wikis = … })` config to VimL. The buffer-side
-- VimL helpers (autoload/nuwiki/config.vim) and the vimwiki compat shim
-- (autoload/vimwiki/vars.vim — used by third-party plugins such as
-- vimwiki-sync) read `g:nuwiki_wikis`, which can't see a config that only
-- lives in Lua. Publishing the resolved list keeps both worlds in sync. (A
-- `g:nuwiki_wikis` / `g:vimwiki_list` config is already VimL-visible, so this
-- only fires for the pure-`setup()` case.)
if type(config.options.wikis) == 'table' and #config.options.wikis > 0 then
vim.g.nuwiki_wikis = config.wikis()
end
-- Neovim 0.7+ resolves filetypes via `vim.filetype.match` before any -- Neovim 0.7+ resolves filetypes via `vim.filetype.match` before any
-- `ftdetect/*.vim` autocmd fires, and its bundled rule for `*.wiki` -- `ftdetect/*.vim` autocmd fires, and its bundled rule for `*.wiki`
-- returns `mediawiki`. Register an authoritative rule here so this -- returns `mediawiki`. Register an authoritative rule here so this
+10 -21
View File
@@ -2,9 +2,9 @@
-- --
-- Strategy: -- Strategy:
-- 1. Look for `bin/nuwiki-ls[.exe]` next to the plugin. -- 1. Look for `bin/nuwiki-ls[.exe]` next to the plugin.
-- 2. If missing, download a release asset for the current target from -- 2. If missing or `g:nuwiki_build_from_source` is set, build with cargo.
-- nuwiki-rs. -- 3. Otherwise try to download a release asset for the current target.
-- 3. On failure, fall back to cloning nuwiki-rs and building with cargo. -- On failure, fall back to cargo.
local M = {} local M = {}
@@ -51,30 +51,16 @@ local function build_from_source(dest)
return false return false
end end
local root = plugin_root() local root = plugin_root()
vim.notify('nuwiki: building nuwiki-ls from source (cargo build --release) …', vim.log.levels.INFO) vim.notify('nuwiki: building from source (cargo build --release) …', vim.log.levels.INFO)
local rs_repo = vim.fn.fnamemodify(root, ':h:h') .. '/nuwiki-rs'
if not vim.uv then vim.uv = vim.loop end
local rs_exists = vim.uv.fs_stat(rs_repo)
if not rs_exists then
vim.notify('nuwiki: cloning nuwiki-rs …', vim.log.levels.INFO)
local _ = vim.fn.system({
'git', 'clone', '--depth', '1',
'https://code.gfran.co/gffranco/nuwiki-rs.git', rs_repo,
})
if vim.v.shell_error ~= 0 then
vim.notify('nuwiki: failed to clone nuwiki-rs', vim.log.levels.ERROR)
return false
end
end
local _ = vim.fn.system({ local _ = vim.fn.system({
'cargo', 'build', '--release', '-p', 'nuwiki-ls', 'cargo', 'build', '--release', '-p', 'nuwiki-ls',
'--manifest-path', rs_repo .. '/Cargo.toml', '--manifest-path', root .. '/Cargo.toml',
}) })
if vim.v.shell_error ~= 0 then if vim.v.shell_error ~= 0 then
vim.notify('nuwiki: cargo build failed', vim.log.levels.ERROR) vim.notify('nuwiki: cargo build failed', vim.log.levels.ERROR)
return false return false
end end
local built = rs_repo .. '/target/release/nuwiki-ls' .. exe_suffix() local built = root .. '/target/release/nuwiki-ls' .. exe_suffix()
vim.fn.system({ 'cp', built, dest }) vim.fn.system({ 'cp', built, dest })
vim.fn.system({ 'chmod', '+x', dest }) vim.fn.system({ 'chmod', '+x', dest })
return vim.fn.executable(dest) == 1 return vim.fn.executable(dest) == 1
@@ -89,7 +75,7 @@ local function download_release(dest)
return false return false
end end
local url = string.format( local url = string.format(
'https://code.gfran.co/gffranco/nuwiki-rs/releases/download/latest/nuwiki-ls-%s.tar.gz', 'https://code.gfran.co/gffranco/nuwiki/releases/latest/download/nuwiki-ls-%s.tar.gz',
target target
) )
local archive = vim.fn.tempname() .. '.tar.gz' local archive = vim.fn.tempname() .. '.tar.gz'
@@ -112,6 +98,9 @@ function M.install()
local dest = M.expected_path() local dest = M.expected_path()
vim.fn.mkdir(vim.fn.fnamemodify(dest, ':h'), 'p') vim.fn.mkdir(vim.fn.fnamemodify(dest, ':h'), 'p')
if vim.g.nuwiki_build_from_source == 1 then
return build_from_source(dest)
end
if download_release(dest) then if download_release(dest) then
vim.notify('nuwiki: installed ' .. dest, vim.log.levels.INFO) vim.notify('nuwiki: installed ' .. dest, vim.log.levels.INFO)
return true return true
+5 -14
View File
@@ -14,22 +14,13 @@ local function command()
return { install.expected_path() } return { install.expected_path() }
end end
-- Build the server config payload. The wiki list comes from the shared -- Merge g:nuwiki_wikis (VimL config) into opts.wikis when the user has not
-- resolver (config.wikis) — native setup() wikis, g:nuwiki_wikis, or an -- passed wikis through setup(). This lets users who configure via a .vim
-- upstream g:vimwiki_list, with global scalar defaults already folded — so the -- file avoid duplicating the list in their Lua setup() call.
-- payload and the buffer-side commands agree on which wikis exist.
local function resolved_opts() local function resolved_opts()
local opts = config.options local opts = config.options
local wikis = config.wikis() if not opts.wikis and vim.g.nuwiki_wikis then
if #wikis > 0 then opts = vim.tbl_extend('force', opts, { wikis = vim.g.nuwiki_wikis })
opts = vim.tbl_extend('force', opts, { wikis = wikis })
else
-- Single-wiki shorthand: fold the global scalars into the top-level payload
-- so the server's single-wiki desugaring honours them.
local globals = config.scalar_globals()
if not vim.tbl_isempty(globals) then
opts = vim.tbl_extend('keep', opts, globals)
end
end end
return opts return opts
end end
+1 -6
View File
@@ -15,7 +15,7 @@ let g:loaded_nuwiki = 1
" Plugin version, surfaced by :VimwikiShowVersion / :NuwikiShowVersion. " Plugin version, surfaced by :VimwikiShowVersion / :NuwikiShowVersion.
" Set before the Neovim early-return below so both clients see the global. " Set before the Neovim early-return below so both clients see the global.
if !exists('g:nuwiki_version') if !exists('g:nuwiki_version')
let g:nuwiki_version = '0.5.0' let g:nuwiki_version = '0.1.0'
endif endif
" Resolve the plugin root NOW, while this script is being sourced and " Resolve the plugin root NOW, while this script is being sourced and
@@ -37,11 +37,6 @@ endif
augroup nuwiki_calendar_detect augroup nuwiki_calendar_detect
autocmd! autocmd!
autocmd FileType vimwiki call s:nuwiki_setup_calendar() autocmd FileType vimwiki call s:nuwiki_setup_calendar()
" Track the wiki of the active buffer so the calendar diaries into the wiki
" you're editing (not always the first one). FileType covers initial load;
" BufEnter covers switching back to an already-open wiki buffer.
autocmd FileType vimwiki call nuwiki#diary#track_wiki(expand('%:p'))
autocmd BufEnter * if &filetype ==# 'vimwiki' | call nuwiki#diary#track_wiki(expand('%:p')) | endif
augroup END augroup END
function! s:nuwiki_setup_calendar() abort function! s:nuwiki_setup_calendar() abort
if !get(g:, 'nuwiki_use_calendar', 1) || get(g:, 'nuwiki_no_calendar', 0) if !get(g:, 'nuwiki_use_calendar', 1) || get(g:, 'nuwiki_no_calendar', 0)
+9 -24
View File
@@ -1,22 +1,13 @@
" scripts/download_bin.vim — install `nuwiki-ls` from a release tarball, " scripts/download_bin.vim — install `nuwiki-ls` from a release tarball,
" falling back to `cargo build --release` from the nuwiki-rs repo. " falling back to `cargo build --release` on failure.
" "
" Invoked by Dein / vim-plug build hooks: " Invoked by Dein / vim-plug build hooks:
" vim -e -s -c "source scripts/download_bin.vim" -c "q" " vim -e -s -c "source scripts/download_bin.vim" -c "q"
"
" That invocation runs in 'compatible' mode, where leading-backslash line
" continuations aren't recognised (they'd raise E10, and any error in silent
" Ex mode makes Vim exit non-zero — a spurious build failure). Reset
" 'cpoptions' to the Vim default for the duration so the script parses, and
" restore it at the end.
let s:cpo_save = &cpo
set cpo&vim
let s:plugin_dir = fnamemodify(resolve(expand('<sfile>:p')), ':h:h') let s:plugin_dir = fnamemodify(resolve(expand('<sfile>:p')), ':h:h')
let s:bin_dir = s:plugin_dir . '/bin' let s:bin_dir = s:plugin_dir . '/bin'
let s:suffix = has('win32') ? '.exe' : '' let s:suffix = has('win32') ? '.exe' : ''
let s:bin = s:bin_dir . '/nuwiki-ls' . s:suffix let s:bin = s:bin_dir . '/nuwiki-ls' . s:suffix
let s:rs_repo = fnamemodify(s:plugin_dir, ':h:h') . '/nuwiki-rs'
call mkdir(s:bin_dir, 'p') call mkdir(s:bin_dir, 'p')
@@ -45,21 +36,13 @@ function! s:build_from_source() abort
echohl ErrorMsg | echom 'nuwiki: cargo not found, cannot build from source' | echohl None echohl ErrorMsg | echom 'nuwiki: cargo not found, cannot build from source' | echohl None
return 0 return 0
endif endif
echom 'nuwiki: cloning nuwiki-rs …'
if !filereadable(s:rs_repo . '/Cargo.toml')
call system('git clone --depth 1 https://code.gfran.co/gffranco/nuwiki-rs.git ' . shellescape(s:rs_repo))
if v:shell_error != 0
echohl ErrorMsg | echom 'nuwiki: failed to clone nuwiki-rs' | echohl None
return 0
endif
endif
echom 'nuwiki: cargo build --release …' echom 'nuwiki: cargo build --release …'
call system('cargo build --release -p nuwiki-ls --manifest-path ' . shellescape(s:rs_repo . '/Cargo.toml')) call system('cargo build --release -p nuwiki-ls --manifest-path ' . shellescape(s:plugin_dir . '/Cargo.toml'))
if v:shell_error != 0 if v:shell_error != 0
echohl ErrorMsg | echom 'nuwiki: cargo build failed' | echohl None echohl ErrorMsg | echom 'nuwiki: cargo build failed' | echohl None
return 0 return 0
endif endif
let l:built = s:rs_repo . '/target/release/nuwiki-ls' . s:suffix let l:built = s:plugin_dir . '/target/release/nuwiki-ls' . s:suffix
call system('cp ' . shellescape(l:built) . ' ' . shellescape(s:bin)) call system('cp ' . shellescape(l:built) . ' ' . shellescape(s:bin))
call system('chmod +x ' . shellescape(s:bin)) call system('chmod +x ' . shellescape(s:bin))
return executable(s:bin) return executable(s:bin)
@@ -73,7 +56,7 @@ function! s:download_release() abort
if !executable('curl') || !executable('tar') if !executable('curl') || !executable('tar')
return 0 return 0
endif endif
let l:url = 'https://code.gfran.co/gffranco/nuwiki-rs/releases/download/latest/nuwiki-ls-' let l:url = 'https://code.gfran.co/gffranco/nuwiki/releases/latest/download/nuwiki-ls-'
\ . l:target . '.tar.gz' \ . l:target . '.tar.gz'
let l:archive = tempname() . '.tar.gz' let l:archive = tempname() . '.tar.gz'
call system('curl -fsSL -o ' . shellescape(l:archive) . ' ' . shellescape(l:url)) call system('curl -fsSL -o ' . shellescape(l:archive) . ' ' . shellescape(l:url))
@@ -91,7 +74,11 @@ function! s:download_release() abort
return executable(s:bin) return executable(s:bin)
endfunction endfunction
if s:download_release() if get(g:, 'nuwiki_build_from_source', 0)
if !s:build_from_source()
echohl ErrorMsg | echom 'nuwiki: source build failed' | echohl None
endif
elseif s:download_release()
echom 'nuwiki: installed ' . s:bin echom 'nuwiki: installed ' . s:bin
else else
echohl WarningMsg | echom 'nuwiki: download failed, falling back to source build' | echohl None echohl WarningMsg | echom 'nuwiki: download failed, falling back to source build' | echohl None
@@ -99,5 +86,3 @@ else
echohl ErrorMsg | echom 'nuwiki: install failed' | echohl None echohl ErrorMsg | echom 'nuwiki: install failed' | echohl None
endif endif
endif endif
let &cpo = s:cpo_save