feat: initial nuwiki Rust workspace (core, lsp, ls)
CI / cargo fmt --check (push) Successful in 59s
CI / cargo clippy (push) Successful in 1m20s
CI / cargo test (push) Successful in 1m29s
CI / editor keymaps (push) Failing after 33s

This commit is contained in:
gffranco
2026-06-24 00:01:59 +00:00
commit 29a4efb6bc
70 changed files with 26264 additions and 0 deletions
+128
View File
@@ -0,0 +1,128 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: "1"
RUSTFLAGS: -D warnings
jobs:
fmt:
name: cargo fmt --check
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- 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
run: |
set -euo pipefail
# Test the minimum supported Neovim version (0.11.0) so we
# 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
curl -fsSL -o /tmp/nvim.tar.gz \
"https://github.com/neovim/neovim/releases/download/${NVIM_VER}/nvim-linux-x86_64.tar.gz"
sudo tar -xzf /tmp/nvim.tar.gz -C /opt
sudo ln -sf /opt/nvim-linux-x86_64/bin/nvim /usr/local/bin/nvim
nvim --version | head -1
# Vim from apt is fine — only the pure-VimL keymaps run there.
sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends vim
vim --version | head -1
- name: run Neovim keymap harness
run: ./development/tests/test-keymaps.sh
- name: run Vim keymap harness
run: ./development/tests/test-keymaps-vim.sh
- name: run Neovim config parity harness
run: ./development/tests/test-config.sh
- name: run Vim config parity harness
run: ./development/tests/test-config-vim.sh
- name: run Neovim calendar integration harness
run: ./development/tests/test-calendar.sh
- name: run Vim calendar integration harness
run: ./development/tests/test-calendar-vim.sh
- name: run Vim vars-shim harness
run: ./development/tests/test-vars-vim.sh
- name: run Vim vimwiki-config compat harness
run: ./development/tests/test-vimwiki-compat-vim.sh
- name: run Neovim global-shorthand harness
run: ./development/tests/test-global-shorthand.sh
- name: run Vim coc-registration harness
run: ./development/tests/test-coc-register-vim.sh
+276
View File
@@ -0,0 +1,276 @@
name: Release
# Releases are cut from the Actions UI: "Run workflow" → enter the version
# (e.g. 0.4.0). The `prepare` job stamps that version into every hardcoded
# spot (scripts/set-version.sh), commits "chore(release): X.Y.Z", and pushes
# the vX.Y.Z tag. The build matrix + release job then run off that tag. No
# more hand-editing versions across Cargo.toml/plugin/nuwiki.vim.
on:
workflow_dispatch:
inputs:
version:
description: 'Release version, no leading v (e.g. 0.4.0)'
required: true
type: string
permissions:
contents: write
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: short
RUSTFLAGS: -D warnings
jobs:
prepare:
name: bump + tag
runs-on: ubuntu-latest
outputs:
version: ${{ steps.stamp.outputs.version }}
tag: ${{ steps.stamp.outputs.tag }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
# Push the release commit + tag back with a write-capable token.
token: ${{ secrets.RELEASE_TOKEN }}
- uses: dtolnay/rust-toolchain@1.83
- name: Cache cargo state
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-prepare-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-prepare-
- name: Validate version + stamp
id: stamp
env:
VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
ver="${VERSION#v}" # tolerate a leading v
if ! printf '%s' "$ver" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.-]+)?$'; then
echo "::error::'$ver' is not a semver version (expected e.g. 0.4.0)"
exit 1
fi
tag="v$ver"
if git rev-parse -q --verify "refs/tags/$tag" >/dev/null \
|| git ls-remote --exit-code --tags origin "$tag" >/dev/null 2>&1; then
echo "::error::tag $tag already exists"
exit 1
fi
bash scripts/set-version.sh "$ver"
echo "version=$ver" >> "$GITHUB_OUTPUT"
echo "tag=$tag" >> "$GITHUB_OUTPUT"
# Gate: never tag code that doesn't build/test. If this fails nothing
# is committed or pushed, so the release simply doesn't happen.
- name: Test
run: cargo test --workspace
- name: Commit, tag, push
env:
VER: ${{ steps.stamp.outputs.version }}
TAG: ${{ steps.stamp.outputs.tag }}
run: |
set -euo pipefail
git config user.name "gitea-actions"
git config user.email "gitea-actions@users.noreply.code.gfran.co"
git add Cargo.toml Cargo.lock \
crates/nuwiki-lsp/Cargo.toml crates/nuwiki-ls/Cargo.toml \
plugin/nuwiki.vim
git commit -m "chore(release): $VER"
git tag -a "$TAG" -m "nuwiki $VER"
git push origin "HEAD:${GITHUB_REF_NAME}"
git push origin "$TAG"
build:
name: build ${{ matrix.target }}
needs: prepare
runs-on: ubuntu-latest
strategy:
fail-fast: false
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
with:
# Build the just-tagged commit, not the branch tip.
ref: ${{ needs.prepare.outputs.tag }}
- 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
env:
VERSION: ${{ needs.prepare.outputs.version }}
run: |
set -euo pipefail
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: [prepare, build]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.prepare.outputs.tag }}
fetch-depth: 0
- name: Download all build artifacts
# 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
env:
TAG: ${{ needs.prepare.outputs.tag }}
run: |
set -euo pipefail
git fetch --tags --force origin >/dev/null 2>&1 || true
# Previous tag = newest tag that isn't the one we just cut.
prev_tag=$(git tag --sort=-version:refname | grep -vxF "$TAG" | head -n1 || echo "")
if [ -n "$prev_tag" ]; then
log=$(git log --oneline "$prev_tag..$TAG")
else
log=$(git log --oneline "$TAG")
fi
cat > release-notes.txt <<EOF
**Full Changelog**: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/compare/${prev_tag:-$(git rev-list --max-parents=0 HEAD)}...${TAG}
**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: ${{ needs.prepare.outputs.tag }}
run: |
set -euo pipefail
if [ -z "${RELEASE_TOKEN:-}" ]; then
echo "::error::RELEASE_TOKEN secret is not set"
exit 1
fi
echo "Releasing $TAG to $GITEA_SERVER/$REPO"
# 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" \
2>/dev/null || 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
notes=$(cat release-notes.txt 2>/dev/null || echo "")
payload=$(jq -n --arg tag "$TAG" --arg name "$TAG" --arg body "$notes" \
'{tag_name: $tag, name: $name, body: $body, draft: false, prerelease: false}')
release_id=$(curl --fail --silent --show-error \
-H "Authorization: token $RELEASE_TOKEN" \
-H "Content-Type: application/json" \
-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."
+19
View File
@@ -0,0 +1,19 @@
# Rust build artifacts
/target
**/*.rs.bk
# Plugin runtime: downloaded LSP binary lives here (see SPEC.md §7.2)
/bin
# Editor / OS noise
.DS_Store
*.swp
*.swo
.idea/
.vscode/
# Vim help tags (regenerated by :helptags)
doc/tags
# Claude Code local session artifacts
.claude/
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.3.0"
[[package]]
name = "nuwiki-ls"
version = "0.3.0"
dependencies = [
"nuwiki-lsp",
"tokio",
]
[[package]]
name = "nuwiki-lsp"
version = "0.3.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"
+18
View File
@@ -0,0 +1,18 @@
[workspace]
resolver = "2"
members = ["crates/*"]
[workspace.package]
version = "0.3.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-rs"
homepage = "https://code.gfran.co/gffranco/nuwiki-rs"
[workspace.lints.rust]
unsafe_code = "forbid"
[workspace.lints.clippy]
all = { level = "warn", priority = -1 }
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for describing the origin of the Work and
reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Support. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or support.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2026 Gabriel Fróes Franco
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Gabriel Fróes Franco
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+26
View File
@@ -0,0 +1,26 @@
# nuwiki-rs
Rust crates powering [nuwiki](https://code.gfran.co/gffranco/nuwiki): the vimwiki-compatible language server and editor plugin for Vim/Neovim.
## Crates
- **nuwiki-core** — Vimwiki lexer, parser, AST, HTML renderer, and date utilities
- **nuwiki-lsp** — Language Server Protocol implementation (tower-lsp)
- **nuwiki-ls** — Binary entry point (stdio server bridge)
## Quick Start
```bash
# Build
cargo build --release -p nuwiki-ls
# Run
cargo run --bin nuwiki-ls
# Tests
cargo test
```
## License
Licensed under MIT or Apache-2.0, at your option. See `LICENSE-MIT` and `LICENSE-APACHE`.
+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));
}
+532
View File
@@ -0,0 +1,532 @@
//! 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 toc_header_heading_wrapped_in_div_toc() {
// The TOC section heading gets a `<div class="toc">` wrapper (vimwiki
// scheme) so `.toc` stylesheet rules apply. The class is on the div, not
// the heading.
let doc = VimwikiSyntax::new().parse("== Contents ==\n");
let out = HtmlRenderer::new()
.with_toc_header("Contents")
.render_to_string(&doc)
.unwrap();
assert!(
out.contains("<div class=\"toc\"><h2 id=\"Contents\">Contents</h2></div>"),
"got: {out}"
);
}
#[test]
fn non_toc_heading_not_wrapped() {
let doc = VimwikiSyntax::new().parse("== Intro ==\n");
let out = HtmlRenderer::new()
.with_toc_header("Contents")
.render_to_string(&doc)
.unwrap();
assert!(out.contains("<h2 id=\"Intro\">Intro</h2>"), "got: {out}");
assert!(!out.contains("class=\"toc\""), "got: {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.3.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.3.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
}
+395
View File
@@ -0,0 +1,395 @@
//! 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(),
);
}
r = r.with_toc_header(&cfg.toc_header);
if !cfg.valid_html_tags.is_empty() {
r = r.with_valid_html_tags(cfg.valid_html_tags.clone());
}
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.
pub(crate) 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
+126
View File
@@ -0,0 +1,126 @@
//! 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),
// Links are an atomic unit for navigation: a cursor anywhere in
// `[[target|description]]` (or `[desc](url)`) should resolve the
// link itself. Don't descend into the description — returning its
// inner Text node leaves goto-definition/references/hover with
// nothing to act on ("No locations found" when on the title).
_ => None,
};
return Some(deeper.unwrap_or(n));
}
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,593 @@
//! 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, None).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,
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,
None
)
.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 ==");
}
+130
View File
@@ -0,0 +1,130 @@
//! 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",
// Both GenerateLinks forms must be advertised so coc/vim-lsp accept
// them (the scoped form was previously sent but unregistered).
"nuwiki.links.generate",
"nuwiki.links.generateForPath",
] {
assert!(names.contains(&name), "missing: {name}");
}
}
#[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}");
}
}
+417
View File
@@ -0,0 +1,417 @@
//! 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,
None,
)
.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_inserts_at_cursor_line() {
// A fresh tags section goes at the cursor line, not the EOF.
let src = "= Notes =\nbody\nmore\n";
let ast = parse(src);
let uri = Url::parse("file:///tmp/p.wiki").unwrap();
let mut snap = BTreeMap::new();
snap.insert("release".to_string(), vec!["Alpha".into()]);
let edit = ops::tag_links_edit(
src,
&ast,
&uri,
Some("release"),
&snap,
true,
"Generated Tags",
1,
0,
Some(1),
)
.expect("edit");
let te = &edit.changes.unwrap()[&uri][0];
assert_eq!(te.range.start.line, 1);
assert_eq!(te.range.start.character, 0);
assert!(
te.new_text.starts_with("\n= Tag: release ="),
"got: {:?}",
te.new_text
);
}
#[test]
fn tag_links_edit_replaces_existing_section() {
let src = "= Tag: release =\n- [[Stale]]\n\n= Other =\n";
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,
None,
)
.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,
None,
)
.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,
None
)
.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}");
}
}
+693
View File
@@ -0,0 +1,693 @@
//! 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 single_wiki_shorthand_honours_top_level_per_wiki_keys() {
// Regression: a single-wiki user sets per-wiki keys at the top level
// (alongside wiki_root); they must reach the synthesized wiki, not just
// file_extension/syntax. Previously toc_header_level etc. were dropped.
let cfg = config_from_json(json!({
"wiki_root": "/tmp/vimwiki",
"toc_header": "Table of Contents",
"toc_header_level": 2,
"links_header_level": 3,
"auto_export": true,
"html_path": "/tmp/out",
}));
assert_eq!(cfg.wikis.len(), 1);
let w = &cfg.wikis[0];
assert_eq!(w.toc_header, "Table of Contents");
assert_eq!(w.toc_header_level, 2);
assert_eq!(w.links_header_level, 3);
assert!(w.html.auto_export);
assert_eq!(w.html.html_path, PathBuf::from("/tmp/out"));
}
#[test]
fn explicit_wikis_list_overrides_legacy_shape() {
let cfg = config_from_json(json!({
"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");
}
#[test]
fn vimwiki_compat_payload_sets_toc_level_per_wiki() {
// The exact shape the Vim client emits when translating g:vimwiki_list +
// g:vimwiki_toc_header_level=2 (drop-in vimwiki compat).
let cfg = config_from_json(json!({
"wiki_root": "~/vimwiki",
"wikis": [
{ "root": "/tmp/personal", "toc_header_level": 2, "html_header_numbering": 2 },
{ "root": "/tmp/ifood", "toc_header_level": 2 },
],
}));
assert_eq!(cfg.wikis.len(), 2);
assert_eq!(cfg.wikis[0].toc_header_level, 2);
assert_eq!(cfg.wikis[0].html.html_header_numbering, 2);
assert_eq!(cfg.wikis[1].toc_header_level, 2);
}
+898
View File
@@ -0,0 +1,898 @@
//! 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, None).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_inserts_at_cursor_line() {
// A fresh TOC goes at the cursor line (0-based) the client sends.
let src = "= One =\nbody text\n== Two ==\nmore\n";
let ast = parse(src);
let uri = Url::parse("file:///tmp/page.wiki").unwrap();
// Cursor on line 1 ("body text").
let edit = ops::toc_edit(src, &ast, &uri, true, "Contents", 1, 0, 0, Some(1)).expect("edit");
let te = &edit.changes.unwrap()[&uri][0];
// Zero-width insert at line 1, column 0.
assert_eq!(te.range.start, te.range.end);
assert_eq!(te.range.start.line, 1);
assert_eq!(te.range.start.character, 0);
// A leading blank separates it from the preceding text.
assert!(
te.new_text.starts_with("\n= Contents ="),
"got: {:?}",
te.new_text
);
}
#[test]
fn toc_edit_cursor_line_clamped_and_existing_replaced_in_place() {
// An existing TOC is replaced in place regardless of the cursor line.
let src = "= Contents =\n- [[#stale|Stale]]\n\n= Real =\n";
let ast = parse(src);
let uri = Url::parse("file:///tmp/page.wiki").unwrap();
let edit = ops::toc_edit(src, &ast, &uri, true, "Contents", 1, 0, 0, Some(99)).expect("edit");
let te = &edit.changes.unwrap()[&uri][0];
// Replacement starts at line 0 (the existing section), not the cursor.
assert_eq!(te.range.start.line, 0);
assert!(te.new_text.contains("[[#Real|Real]]"));
}
#[test]
fn toc_edit_replaces_existing_toc() {
let src = "= Contents =\n- [[#stale|Stale]]\n\n= Real =\n";
let ast = parse(src);
let uri = Url::parse("file:///tmp/page.wiki").unwrap();
let edit =
ops::toc_edit(src, &ast, &uri, true, "Contents", 1, 0, 0, None).expect("got an edit");
let 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, None).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,
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_inserts_at_cursor_line() {
// A fresh Generated Links section goes at the cursor line, not the EOF.
let src = "= Home =\nbody\nmore\n";
let ast = parse(src);
let uri = Url::parse("file:///tmp/page.wiki").unwrap();
let pages = vec!["A".into()];
let edit = ops::links_edit(
src,
&ast,
&uri,
"Home",
&pages,
true,
"Generated Links",
1,
0,
None,
Some(1),
)
.expect("edit");
let te = &edit.changes.unwrap()[&uri][0];
assert_eq!(te.range.start.line, 1);
assert_eq!(te.range.start.character, 0);
assert!(
te.new_text.starts_with("\n= Generated Links ="),
"got: {:?}",
te.new_text
);
}
#[test]
fn links_edit_replaces_when_section_present() {
let src = "= Generated Links =\n- [[Stale]]\n\n= Real =\n";
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,
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,
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}");
}
}
+240
View File
@@ -0,0 +1,240 @@
//! 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_wikilink_when_cursor_on_description() {
// Regression: pressing <CR> on the *title* part of a piped link used to
// descend into the description's Text node, so goto-definition saw a Text
// (not a WikiLink) and reported "No locations found".
let src = "see [[Other|My Title]] now\n";
let doc = parse(src);
// Byte col 16 is inside "Title" (the description), past the '|' at 11.
let node = find_inline_at(&doc, 0, 16).expect("wikilink at cursor");
assert!(matches!(node, InlineNode::WikiLink(_)));
}
#[test]
fn find_inline_at_returns_none_for_plain_text() {
let src = "just text\n";
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);
}