commit 29a4efb6bc5b4a15d8145399fac7fb66fb25463b Author: gffranco Date: Wed Jun 24 00:01:59 2026 +0000 feat: initial nuwiki Rust workspace (core, lsp, ls) diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml new file mode 100644 index 0000000..0c79516 --- /dev/null +++ b/.gitea/workflows/ci.yaml @@ -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 diff --git a/.gitea/workflows/release.yaml b/.gitea/workflows/release.yaml new file mode 100644 index 0000000..ac05433 --- /dev/null +++ b/.gitea/workflows/release.yaml @@ -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//), 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 </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//), 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." diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..feab64e --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..bf9b74f --- /dev/null +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..3d15513 --- /dev/null +++ b/Cargo.toml @@ -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 "] +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 } diff --git a/LICENSE-APACHE b/LICENSE-APACHE new file mode 100644 index 0000000..905b115 --- /dev/null +++ b/LICENSE-APACHE @@ -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. diff --git a/LICENSE-MIT b/LICENSE-MIT new file mode 100644 index 0000000..3257bc0 --- /dev/null +++ b/LICENSE-MIT @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..2747de2 --- /dev/null +++ b/README.md @@ -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`. \ No newline at end of file diff --git a/crates/nuwiki-core/Cargo.toml b/crates/nuwiki-core/Cargo.toml new file mode 100644 index 0000000..8b9eb13 --- /dev/null +++ b/crates/nuwiki-core/Cargo.toml @@ -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] diff --git a/crates/nuwiki-core/src/ast/block.rs b/crates/nuwiki-core/src/ast/block.rs new file mode 100644 index 0000000..52ccf8d --- /dev/null +++ b/crates/nuwiki-core/src/ast/block.rs @@ -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, + pub centered: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ParagraphNode { + pub span: Span, + pub children: Vec, +} + +#[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, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PreformattedNode { + pub span: Span, + pub content: String, + pub language: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MathBlockNode { + pub span: Span, + pub content: String, + pub environment: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ListNode { + pub span: Span, + pub ordered: bool, + pub symbol: ListSymbol, + pub items: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ListItemNode { + pub span: Span, + pub symbol: ListSymbol, + pub level: usize, + pub checkbox: Option, + pub children: Vec, + pub sublist: Option, +} + +#[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, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DefinitionItemNode { + pub span: Span, + pub term: Option>, + pub definitions: Vec>, +} + +#[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, + 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, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TableRowNode { + pub span: Span, + pub cells: Vec, + pub is_header: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TableCellNode { + pub span: Span, + pub children: Vec, + 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, + 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, +} diff --git a/crates/nuwiki-core/src/ast/inline.rs b/crates/nuwiki-core/src/ast/inline.rs new file mode 100644 index 0000000..2e082f2 --- /dev/null +++ b/crates/nuwiki-core/src/ast/inline.rs @@ -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 `
` + /// (`= 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, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ItalicNode { + pub span: Span, + pub children: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BoldItalicNode { + pub span: Span, + pub children: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StrikethroughNode { + pub span: Span, + pub children: Vec, +} + +#[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, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SubscriptNode { + pub span: Span, + pub children: Vec, +} + +#[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 +/// `` 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, +} diff --git a/crates/nuwiki-core/src/ast/link.rs b/crates/nuwiki-core/src/ast/link.rs new file mode 100644 index 0000000..e3b230c --- /dev/null +++ b/crates/nuwiki-core/src/ast/link.rs @@ -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, + pub wiki_index: Option, + pub wiki_name: Option, + pub anchor: Option, + 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>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExternalLinkNode { + pub span: Span, + pub url: String, + pub description: Option>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TransclusionNode { + pub span: Span, + pub url: String, + pub alt: Option, + pub attrs: HashMap, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RawUrlNode { + pub span: Span, + pub url: String, +} diff --git a/crates/nuwiki-core/src/ast/mod.rs b/crates/nuwiki-core/src/ast/mod.rs new file mode 100644 index 0000000..69c070a --- /dev/null +++ b/crates/nuwiki-core/src/ast/mod.rs @@ -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, + 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, + pub nohtml: bool, + pub template: Option, + pub date: Option, + /// File-scope tags parsed by the tag lexer. Convenience + /// projection of the `BlockNode::Tag` nodes whose scope is `File`. + pub tags: Vec, +} diff --git a/crates/nuwiki-core/src/ast/span.rs b/crates/nuwiki-core/src/ast/span.rs new file mode 100644 index 0000000..aefc11c --- /dev/null +++ b/crates/nuwiki-core/src/ast/span.rs @@ -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 } + } +} diff --git a/crates/nuwiki-core/src/ast/visit.rs b/crates/nuwiki-core/src/ast/visit.rs new file mode 100644 index 0000000..8471398 --- /dev/null +++ b/crates/nuwiki-core/src/ast/visit.rs @@ -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: &mut V, node: &DocumentNode) { + for child in &node.children { + v.visit_block(child); + } +} + +pub fn walk_block(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: &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: &mut V, node: &ListNode) { + for item in &node.items { + v.visit_list_item(item); + } +} + +pub fn walk_list_item(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: &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: &mut V, node: &TableNode) { + for row in &node.rows { + v.visit_table_row(row); + } +} + +pub fn walk_table_row(v: &mut V, node: &TableRowNode) { + for cell in &node.cells { + v.visit_table_cell(cell); + } +} diff --git a/crates/nuwiki-core/src/date.rs b/crates/nuwiki-core/src/date.rs new file mode 100644 index 0000000..ef5e29a --- /dev/null +++ b/crates/nuwiki-core/src/date.rs @@ -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 +//! () — 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 { + 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 { + 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 { + 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 { + 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 { + // 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, + } +} diff --git a/crates/nuwiki-core/src/lib.rs b/crates/nuwiki-core/src/lib.rs new file mode 100644 index 0000000..a032210 --- /dev/null +++ b/crates/nuwiki-core/src/lib.rs @@ -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; diff --git a/crates/nuwiki-core/src/listsyms.rs b/crates/nuwiki-core/src/listsyms.rs new file mode 100644 index 0000000..0186480 --- /dev/null +++ b/crates/nuwiki-core/src/listsyms.rs @@ -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, + 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 = 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 { + 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 { + 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), '✓'); + } +} diff --git a/crates/nuwiki-core/src/render/html.rs b/crates/nuwiki-core/src/render/html.rs new file mode 100644 index 0000000..3d2558b --- /dev/null +++ b/crates/nuwiki-core/src/render/html.rs @@ -0,0 +1,1134 @@ +//! HTML renderer. +//! +//! Walks a `DocumentNode` and writes HTML5 fragments. The renderer is +//! configured with a link resolver (callback that turns a `LinkTarget` into +//! a URL string) and, optionally, an enclosing template with substitution +//! placeholders. +//! +//! Substitution model: `{{content}}` and `{{title}}` are computed from +//! the rendered body and the document's metadata; `with_var(k, v)` / +//! `with_vars(map)` add arbitrary key→value pairs (`{{date}}`, +//! `{{root_path}}`, `{{toc}}`). Order: `{{content}}` is +//! substituted first so a body that happens to contain a literal +//! `{{title}}` isn't itself rewritten in the next pass. +//! +//! Both delimiter styles are recognised for every placeholder: nuwiki's +//! native `{{key}}` and vimwiki's `%key%` (`%title%`, `%content%`, +//! `%root_path%`, `%date%`, `%wiki_css%`, …). This lets stock vimwiki +//! templates render unchanged. + +use std::collections::HashMap; +use std::io::{self, Write}; +use std::sync::Arc; + +use crate::ast::{ + BlockNode, BlockquoteNode, BoldItalicNode, BoldNode, CheckboxState, CodeNode, ColorNode, + CommentNode, DefinitionItemNode, DefinitionListNode, DocumentNode, ErrorNode, ExternalLinkNode, + HeadingNode, HorizontalRuleNode, InlineNode, ItalicNode, Keyword, KeywordNode, LinkKind, + LinkTarget, ListItemNode, ListNode, MathBlockNode, MathInlineNode, ParagraphNode, + PreformattedNode, RawUrlNode, StrikethroughNode, SubscriptNode, SuperscriptNode, TableCellNode, + TableNode, TableRowNode, TagNode, TextNode, TransclusionNode, WikiLinkNode, +}; + +use super::Renderer; + +/// Boxed link-resolution callback. Held in an `Arc` so the renderer is +/// cheap to clone and stays `Send + Sync`. +pub type LinkResolver = Arc String + Send + Sync>; + +#[derive(Clone)] +pub struct HtmlRenderer { + link_resolver: LinkResolver, + template: Option, + vars: HashMap, + /// `color_dic`-style override: vimwiki colour-tag names → CSS + /// values (`"red"` / `"#ffe119"` / `"oklch(…)"`). Unspecified + /// names fall through to the default `class="color-"` + /// rendering. Matches vimwiki's `color_dic`. + colors: HashMap, + /// vimwiki `color_tag_template`: the HTML emitted for a colour span whose + /// name resolves in `colors`. `__STYLE__` expands to the inline style + /// (`color:`) and `__CONTENT__` to the rendered inner HTML. The + /// default matches upstream's ``. + color_template: String, + /// vimwiki `html_header_numbering`: the heading level at which automatic + /// section numbering begins (`0` = off, the default). When `>= 1`, every + /// heading at that level or deeper is prefixed with a dotted section + /// number (`1`, `1.1`, `1.2`, …); shallower headings stay unnumbered. + header_numbering: u8, + /// vimwiki `html_header_numbering_sym`: a symbol appended after the + /// section number (e.g. `.` → `1.2. Heading`). Empty by default. + header_numbering_sym: String, + /// vimwiki `toc_header`: the heading text that identifies the TOC + /// section. A heading matching this value (case-insensitive) is wrapped + /// in `
` on export, matching upstream so vimwiki's + /// stylesheet applies. + toc_header: String, + /// vimwiki `valid_html_tags`: inline HTML tag names passed through to + /// export verbatim (instead of escaping `<`/`>`). Empty = escape all. + /// `span` is always allowed so colour spans render. + valid_html_tags: Vec, + /// vimwiki `emoji_enable` (default `false` in the renderer; set from + /// config): substitute `:alias:` shortcodes with their emoji glyph. + emoji_enable: bool, + /// vimwiki `text_ignore_newline` (default `true`): a soft break in a + /// paragraph renders as a space; `false` → `
`. + text_ignore_newline: bool, + /// vimwiki `list_ignore_newline` (default `true`): a soft break in a list + /// item renders as a space; `false` → `
`. + list_ignore_newline: bool, +} + +impl Default for HtmlRenderer { + fn default() -> Self { + Self::new() + } +} + +impl HtmlRenderer { + pub fn new() -> Self { + Self { + link_resolver: Arc::new(default_link_resolver), + template: None, + vars: HashMap::new(), + colors: HashMap::new(), + color_template: "__CONTENT__".to_string(), + header_numbering: 0, + header_numbering_sym: String::new(), + toc_header: String::new(), + valid_html_tags: Vec::new(), + emoji_enable: false, + text_ignore_newline: true, + list_ignore_newline: true, + } + } + + /// Set vimwiki `text_ignore_newline` / `list_ignore_newline`. When `false`, + /// a soft line break in a paragraph / list item renders as `
` + /// instead of a space. + pub fn with_newline_handling(mut self, text_ignore: bool, list_ignore: bool) -> Self { + self.text_ignore_newline = text_ignore; + self.list_ignore_newline = list_ignore; + self + } + + /// Set the inline HTML tags allowed through export unescaped (vimwiki + /// `valid_html_tags`). `span` is always added so colour spans render. + pub fn with_valid_html_tags(mut self, mut tags: Vec) -> Self { + if !tags.iter().any(|t| t.eq_ignore_ascii_case("span")) { + tags.push("span".to_string()); + } + self.valid_html_tags = tags; + self + } + + /// Enable `:alias:` → emoji substitution during export (vimwiki + /// `emoji_enable`). + pub fn with_emoji(mut self, enable: bool) -> Self { + self.emoji_enable = enable; + self + } + + /// Override the link resolver. The callback is invoked for every + /// wikilink and is expected to return a URL string. + pub fn with_link_resolver(mut self, f: F) -> Self + where + F: Fn(&LinkTarget) -> String + Send + Sync + 'static, + { + self.link_resolver = Arc::new(f); + self + } + + /// Wrap the rendered body in a template. `{{title}}` and `{{content}}` + /// are substituted automatically; additional `{{key}}` placeholders + /// resolve via `with_var` / `with_vars`. Anything unresolved passes + /// through verbatim. + pub fn with_template(mut self, template: impl Into) -> Self { + self.template = Some(template.into()); + self + } + + /// Add a single template variable. Re-use to overwrite. + pub fn with_var(mut self, key: impl Into, value: impl Into) -> Self { + self.vars.insert(key.into(), value.into()); + self + } + + /// Replace the entire template variable map. + pub fn with_vars(mut self, vars: HashMap) -> Self { + self.vars = vars; + self + } + + /// Supply a `color_dic`: vimwiki colour-tag names mapped to their + /// CSS values. A name listed here is rendered as `style="color:V"`; + /// missing names fall through to `class="color-"`. + pub fn with_colors(mut self, colors: HashMap) -> Self { + self.colors = colors; + self + } + + /// Override the colour-span template (vimwiki `color_tag_template`). + /// `__STYLE__` is replaced with the resolved `color:` style and + /// `__CONTENT__` with the rendered inner HTML. An empty string keeps the + /// built-in default. + pub fn with_color_template(mut self, template: impl Into) -> Self { + let template = template.into(); + if !template.is_empty() { + self.color_template = template; + } + self + } + + /// Enable vimwiki-style HTML section numbering. `level` is the heading + /// level at which numbering starts (`0` disables it); `sym` is appended + /// after the number. Mirrors `html_header_numbering` / + /// `html_header_numbering_sym`. + pub fn with_header_numbering(mut self, level: u8, sym: impl Into) -> Self { + self.header_numbering = level; + self.header_numbering_sym = sym.into(); + self + } + + /// Set the vimwiki TOC heading text. When a heading matches this value + /// (case-insensitive) it gets `class="toc"` in HTML export. + pub fn with_toc_header(mut self, header: impl Into) -> Self { + self.toc_header = header.into(); + self + } +} + +/// Running state for vimwiki-style HTML section numbering +/// (`html_header_numbering`). Headings are visited in document order; each +/// at or below the start level bumps its depth's counter, resets deeper +/// counters, and yields a dotted prefix (`1`, `1.1`, `2`, …). +struct HeadingNumberer { + /// Heading level numbering starts at (`0` disables it). + start: u8, + /// Symbol appended after the dotted number. + sym: String, + /// Per-depth counters, indexed by `level - start` (levels clamp to 1–6, + /// so the depth is always `0..=5`). + counters: [u32; 6], +} + +impl HeadingNumberer { + fn new(start: u8, sym: &str) -> Self { + Self { + start, + sym: sym.to_string(), + counters: [0; 6], + } + } + + /// Advance to a heading at `level` (1–6) and return its numeric prefix, + /// including the trailing symbol and a separating space (e.g. `"1.2. "`). + /// Returns an empty string when numbering is off or the heading is + /// shallower than the start level. + fn prefix(&mut self, level: u8) -> String { + if self.start == 0 || level < self.start { + return String::new(); + } + let depth = (level - self.start) as usize; + self.counters[depth] += 1; + for c in self.counters.iter_mut().skip(depth + 1) { + *c = 0; + } + let num = self.counters[..=depth] + .iter() + .map(|c| c.to_string()) + .collect::>() + .join("."); + format!("{num}{} ", self.sym) + } +} + +impl Renderer for HtmlRenderer { + fn render(&self, doc: &DocumentNode, w: &mut dyn Write) -> io::Result<()> { + if let Some(template) = &self.template { + let mut body = Vec::new(); + self.render_body(doc, &mut body)?; + let body_str = std::str::from_utf8(&body) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; + let title = doc.metadata.title.as_deref().unwrap_or(""); + // Substitute `content` before `title` so a body that legitimately + // contains a literal title placeholder isn't rewritten. Both the + // native `{{key}}` and vimwiki's `%key%` delimiters are accepted so + // stock vimwiki templates render unchanged. + let mut out = template.replace("{{content}}", body_str); + out = out.replace("%content%", body_str); + out = out.replace("{{title}}", title); + out = out.replace("%title%", title); + for (k, v) in &self.vars { + out = out.replace(&format!("{{{{{k}}}}}"), v); + out = out.replace(&format!("%{k}%"), v); + } + w.write_all(out.as_bytes())?; + } else { + self.render_body(doc, w)?; + } + Ok(()) + } +} + +impl HtmlRenderer { + fn render_body(&self, doc: &DocumentNode, w: &mut dyn Write) -> io::Result<()> { + // Section numbering runs over top-level headings in document order, + // mirroring vimwiki's line-by-line scan. Nested headings (in lists, + // quotes) go through `render_block` and are left unnumbered, matching + // upstream which only numbers document-level headers. + let mut numberer = HeadingNumberer::new(self.header_numbering, &self.header_numbering_sym); + for block in &doc.children { + if let BlockNode::Heading(n) = block { + let number = numberer.prefix(n.level.clamp(1, 6)); + self.render_heading(n, &number, w)?; + } else { + self.render_block(block, w)?; + } + } + Ok(()) + } + + fn render_block(&self, block: &BlockNode, w: &mut dyn Write) -> io::Result<()> { + match block { + BlockNode::Heading(n) => self.render_heading(n, "", w), + BlockNode::Paragraph(n) => self.render_paragraph(n, w), + BlockNode::HorizontalRule(n) => self.render_hr(n, w), + BlockNode::Blockquote(n) => self.render_blockquote(n, w), + BlockNode::Preformatted(n) => self.render_preformatted(n, w), + BlockNode::MathBlock(n) => self.render_math_block(n, w), + BlockNode::List(n) => self.render_list(n, w), + BlockNode::DefinitionList(n) => self.render_definition_list(n, w), + BlockNode::Table(n) => self.render_table(n, w), + BlockNode::Comment(n) => self.render_comment(n, w), + BlockNode::Tag(n) => self.render_tag(n, w), + BlockNode::Error(n) => self.render_error(n, w), + } + } + + fn render_tag(&self, n: &TagNode, w: &mut dyn Write) -> io::Result<()> { + // `id="tag-…"` lets `[[Page#tag-name]]` jump to the rendered tag. + w.write_all(b"
")?; + for (i, name) in n.tags.iter().enumerate() { + if i > 0 { + w.write_all(b" ")?; + } + w.write_all(b"")?; + write_escaped(name, w)?; + w.write_all(b"")?; + } + w.write_all(b"
\n") + } + + /// Render a heading. `number` is the optional section-number prefix + /// (already including its trailing symbol and space, e.g. `"1.2. "`); + /// pass `""` for no numbering. + fn render_heading(&self, n: &HeadingNode, number: &str, w: &mut dyn Write) -> io::Result<()> { + let level = n.level.clamp(1, 6); + let anchor = crate::ast::inline_text(&n.children); + // The TOC section heading (vimwiki `toc_header`) is wrapped in a + // `
`, matching upstream's HTML so vimwiki's stylesheet + // (`.toc { … }`) applies. The class goes on the wrapping div, not the + // heading element. + let is_toc = !self.toc_header.is_empty() && anchor.eq_ignore_ascii_case(&self.toc_header); + if is_toc { + w.write_all(b"
")?; + } + let class = if n.centered { + " class=\"centered\"" + } else { + "" + }; + write!(w, "")?; + if !number.is_empty() { + write_escaped(number, w)?; + } + self.render_inlines(&n.children, w)?; + write!(w, "")?; + if is_toc { + w.write_all(b"
")?; + } + writeln!(w) + } + + fn render_paragraph(&self, n: &ParagraphNode, w: &mut dyn Write) -> io::Result<()> { + w.write_all(b"

")?; + let sb = if self.text_ignore_newline { + " " + } else { + "
" + }; + self.render_inlines_break(&n.children, sb, w)?; + w.write_all(b"

\n") + } + + fn render_hr(&self, _n: &HorizontalRuleNode, w: &mut dyn Write) -> io::Result<()> { + w.write_all(b"
\n") + } + + fn render_blockquote(&self, n: &BlockquoteNode, w: &mut dyn Write) -> io::Result<()> { + w.write_all(b"
\n")?; + for child in &n.children { + self.render_block(child, w)?; + } + w.write_all(b"
\n") + } + + fn render_preformatted(&self, n: &PreformattedNode, w: &mut dyn Write) -> io::Result<()> { + match &n.language { + Some(lang) => { + w.write_all(b"
")?;
+            }
+            None => w.write_all(b"
")?,
+        }
+        write_escaped(&n.content, w)?;
+        w.write_all(b"
\n") + } + + fn render_math_block(&self, n: &MathBlockNode, w: &mut dyn Write) -> io::Result<()> { + // No actual TeX rendering — caller is expected to run MathJax / KaTeX + // over the wrapped node. The delimiters match vimwiki convention. + match &n.environment { + Some(env) => writeln!( + w, + "
\\begin{{{env}}}", + escape(env) + )?, + None => w.write_all(b"
\\[\n")?, + } + write_escaped(&n.content, w)?; + match &n.environment { + Some(env) => write!(w, "\n\\end{{{env}}}
\n", env = escape(env))?, + None => w.write_all(b"\n\\]
\n")?, + } + Ok(()) + } + + fn render_list(&self, n: &ListNode, w: &mut dyn Write) -> io::Result<()> { + let tag = if n.ordered { "ol" } else { "ul" }; + writeln!(w, "<{tag}>")?; + for item in &n.items { + self.render_list_item(item, w)?; + } + writeln!(w, "") + } + + fn render_list_item(&self, n: &ListItemNode, w: &mut dyn Write) -> io::Result<()> { + match n.checkbox { + Some(state) => { + // vimwiki-compatible checkbox classes: `[ ]`→done0, `[.]`→done1, + // `[o]`→done2, `[O]`→done3, `[X]`→done4, `[-]`→rejected. The + // checkbox glyph/progress fill is drawn entirely by the + // stylesheet (`li.doneN::before`); vimwiki emits no ``, + // so neither do we — an input would double-render against it. + let class = match state { + CheckboxState::Empty => "done0", + CheckboxState::Quarter => "done1", + CheckboxState::Half => "done2", + CheckboxState::ThreeQuarters => "done3", + CheckboxState::Done => "done4", + CheckboxState::Rejected => "rejected", + }; + write!(w, "
  • ")?; + } + None => w.write_all(b"
  • ")?, + } + let sb = if self.list_ignore_newline { + " " + } else { + "
    " + }; + self.render_inlines_break(&n.children, sb, w)?; + if let Some(sublist) = &n.sublist { + w.write_all(b"\n")?; + self.render_list(sublist, w)?; + } + w.write_all(b"
  • \n") + } + + fn render_definition_list(&self, n: &DefinitionListNode, w: &mut dyn Write) -> io::Result<()> { + w.write_all(b"
    \n")?; + for item in &n.items { + self.render_definition_item(item, w)?; + } + w.write_all(b"
    \n") + } + + fn render_definition_item(&self, n: &DefinitionItemNode, w: &mut dyn Write) -> io::Result<()> { + if let Some(term) = &n.term { + w.write_all(b"
    ")?; + self.render_inlines(term, w)?; + w.write_all(b"
    \n")?; + } + for def in &n.definitions { + w.write_all(b"
    ")?; + self.render_inlines(def, w)?; + w.write_all(b"
    \n")?; + } + Ok(()) + } + + fn render_table(&self, n: &TableNode, w: &mut dyn Write) -> io::Result<()> { + // Pre-compute per-cell rowspan/colspan + suppression so we can + // emit valid HTML colspan/rowspan attributes instead of CSS + // class markers. `col_span` cells get folded into the colspan + // of the lead cell to their left; `row_span` cells get folded + // into the rowspan of the lead cell directly above. + let layout = table_spans(n); + w.write_all(b"\n")?; + let mut in_thead = false; + let mut in_tbody = false; + for (row_idx, row) in n.rows.iter().enumerate() { + if row.is_header { + if !in_thead { + w.write_all(b"\n")?; + in_thead = true; + } + } else { + if in_thead { + w.write_all(b"\n")?; + in_thead = false; + } + if !in_tbody { + w.write_all(b"\n")?; + in_tbody = true; + } + } + self.render_table_row(row, row_idx, &layout, &n.alignments, w)?; + } + if in_thead { + w.write_all(b"\n")?; + } + if in_tbody { + w.write_all(b"\n")?; + } + w.write_all(b"
    \n") + } + + fn render_table_row( + &self, + n: &TableRowNode, + row_idx: usize, + layout: &[Vec], + alignments: &[crate::ast::TableAlign], + w: &mut dyn Write, + ) -> io::Result<()> { + w.write_all(b"")?; + for (col_idx, cell) in n.cells.iter().enumerate() { + let info = layout + .get(row_idx) + .and_then(|r| r.get(col_idx)) + .copied() + .unwrap_or(CellLayout::Lead { + colspan: 1, + rowspan: 1, + }); + match info { + CellLayout::Skip => continue, + CellLayout::Lead { colspan, rowspan } => { + let align = alignments + .get(col_idx) + .copied() + .unwrap_or(crate::ast::TableAlign::Default); + self.render_table_cell(cell, n.is_header, colspan, rowspan, align, w)?; + } + } + } + w.write_all(b"\n") + } + + fn render_table_cell( + &self, + n: &TableCellNode, + is_header: bool, + colspan: u32, + rowspan: u32, + align: crate::ast::TableAlign, + w: &mut dyn Write, + ) -> io::Result<()> { + let tag = if is_header { "th" } else { "td" }; + write!(w, "<{tag}")?; + if colspan > 1 { + write!(w, " colspan=\"{colspan}\"")?; + } + if rowspan > 1 { + write!(w, " rowspan=\"{rowspan}\"")?; + } + let align_attr = match align { + crate::ast::TableAlign::Left => Some("left"), + crate::ast::TableAlign::Right => Some("right"), + crate::ast::TableAlign::Center => Some("center"), + crate::ast::TableAlign::Default => None, + }; + if let Some(a) = align_attr { + write!(w, " style=\"text-align: {a};\"")?; + } + write!(w, ">")?; + self.render_inlines(&n.children, w)?; + write!(w, "") + } + + fn render_comment(&self, n: &CommentNode, w: &mut dyn Write) -> io::Result<()> { + // Emit as an HTML comment so it survives round-trips through view-source + // but isn't rendered to the reader. `--` inside a comment is illegal + // in HTML, so collapse runs. + let safe = n.content.replace("--", "- -"); + writeln!(w, "") + } + + fn render_error(&self, n: &ErrorNode, w: &mut dyn Write) -> io::Result<()> { + write!(w, "")?; + write_escaped(&n.raw, w)?; + w.write_all(b"") + } + + // ===== Inline ===== + + fn render_inlines(&self, nodes: &[InlineNode], w: &mut dyn Write) -> io::Result<()> { + for n in nodes { + self.render_inline(n, w)?; + } + Ok(()) + } + + /// Like [`render_inlines`] but renders top-level soft breaks as `soft_break` + /// (a space or `
    `). Used by paragraphs / list items, which are the + /// only blocks that contain `SoftBreak` nodes. + fn render_inlines_break( + &self, + nodes: &[InlineNode], + soft_break: &str, + w: &mut dyn Write, + ) -> io::Result<()> { + for n in nodes { + match n { + InlineNode::SoftBreak(_) => w.write_all(soft_break.as_bytes())?, + _ => self.render_inline(n, w)?, + } + } + Ok(()) + } + + fn render_inline(&self, n: &InlineNode, w: &mut dyn Write) -> io::Result<()> { + match n { + InlineNode::Text(t) => self.render_text(t, w), + InlineNode::Bold(t) => self.render_bold(t, w), + InlineNode::Italic(t) => self.render_italic(t, w), + InlineNode::BoldItalic(t) => self.render_bold_italic(t, w), + InlineNode::Strikethrough(t) => self.render_strikethrough(t, w), + InlineNode::Code(t) => self.render_code(t, w), + InlineNode::Superscript(t) => self.render_superscript(t, w), + InlineNode::Subscript(t) => self.render_subscript(t, w), + InlineNode::MathInline(t) => self.render_math_inline(t, w), + InlineNode::Keyword(t) => self.render_keyword(t, w), + InlineNode::Color(t) => self.render_color(t, w), + InlineNode::WikiLink(t) => self.render_wikilink(t, w), + InlineNode::ExternalLink(t) => self.render_external_link(t, w), + InlineNode::Transclusion(t) => self.render_transclusion(t, w), + InlineNode::RawUrl(t) => self.render_raw_url(t, w), + // Default rendering (e.g. inside a heading): a space. Paragraphs + // and list items use render_inlines_break for `
    ` support. + InlineNode::SoftBreak(_) => w.write_all(b" "), + } + } + + fn render_text(&self, n: &TextNode, w: &mut dyn Write) -> io::Result<()> { + let content = if self.emoji_enable { + substitute_emoji(&n.content) + } else { + std::borrow::Cow::Borrowed(n.content.as_str()) + }; + if self.valid_html_tags.is_empty() { + write_escaped(&content, w) + } else { + write_escaped_allowing(&content, &self.valid_html_tags, w) + } + } + + fn render_bold(&self, n: &BoldNode, w: &mut dyn Write) -> io::Result<()> { + w.write_all(b"")?; + self.render_inlines(&n.children, w)?; + w.write_all(b"") + } + + fn render_italic(&self, n: &ItalicNode, w: &mut dyn Write) -> io::Result<()> { + w.write_all(b"")?; + self.render_inlines(&n.children, w)?; + w.write_all(b"") + } + + fn render_bold_italic(&self, n: &BoldItalicNode, w: &mut dyn Write) -> io::Result<()> { + w.write_all(b"")?; + self.render_inlines(&n.children, w)?; + w.write_all(b"") + } + + fn render_strikethrough(&self, n: &StrikethroughNode, w: &mut dyn Write) -> io::Result<()> { + w.write_all(b"")?; + self.render_inlines(&n.children, w)?; + w.write_all(b"") + } + + fn render_code(&self, n: &CodeNode, w: &mut dyn Write) -> io::Result<()> { + w.write_all(b"")?; + write_escaped(&n.content, w)?; + w.write_all(b"") + } + + fn render_superscript(&self, n: &SuperscriptNode, w: &mut dyn Write) -> io::Result<()> { + w.write_all(b"")?; + self.render_inlines(&n.children, w)?; + w.write_all(b"") + } + + fn render_subscript(&self, n: &SubscriptNode, w: &mut dyn Write) -> io::Result<()> { + w.write_all(b"")?; + self.render_inlines(&n.children, w)?; + w.write_all(b"") + } + + fn render_math_inline(&self, n: &MathInlineNode, w: &mut dyn Write) -> io::Result<()> { + w.write_all(b"\\(")?; + write_escaped(&n.content, w)?; + w.write_all(b"\\)") + } + + fn render_keyword(&self, n: &KeywordNode, w: &mut dyn Write) -> io::Result<()> { + // One class per keyword so stylesheets can colour them independently + // (e.g. red TODO/FIXME/XXX vs green DONE/FIXED/STARTED). TODO keeps the + // vimwiki `todo` class so stock vimwiki CSS still styles it. + let (label, class) = match n.keyword { + Keyword::Todo => ("TODO", "todo"), + Keyword::Done => ("DONE", "done"), + Keyword::Started => ("STARTED", "started"), + Keyword::Fixme => ("FIXME", "fixme"), + Keyword::Fixed => ("FIXED", "fixed"), + Keyword::Xxx => ("XXX", "xxx"), + Keyword::Stopped => ("STOPPED", "stopped"), + }; + write!(w, "{label}") + } + + fn render_color(&self, n: &ColorNode, w: &mut dyn Write) -> io::Result<()> { + // Render the inner content up front so it can be slotted into the + // colour template. + let mut content = Vec::new(); + self.render_inlines(&n.children, &mut content)?; + let content = String::from_utf8_lossy(&content); + + if let Some(css) = self.colors.get(&n.color) { + // Known colour name: expand `color_tag_template` with the resolved + // inline style and rendered content. + let mut style = b"color:".to_vec(); + write_escaped(css, &mut style)?; + let style = String::from_utf8_lossy(&style); + let html = self + .color_template + .replace("__STYLE__", &style) + .replace("__CONTENT__", &content); + w.write_all(html.as_bytes()) + } else { + // Unknown name: fall back to a class hook the stylesheet can target + // (the template only models the inline-style case). + w.write_all(b"")?; + w.write_all(content.as_bytes())?; + w.write_all(b"") + } + } + + fn render_wikilink(&self, n: &WikiLinkNode, w: &mut dyn Write) -> io::Result<()> { + let href = (self.link_resolver)(&n.target); + w.write_all(b"")?; + match &n.description { + Some(desc) => self.render_inlines(desc, w)?, + None => { + let label = n + .target + .path + .clone() + .or_else(|| n.target.anchor.clone()) + .unwrap_or_else(|| href.clone()); + write_escaped(&label, w)?; + } + } + w.write_all(b"") + } + + fn render_external_link(&self, n: &ExternalLinkNode, w: &mut dyn Write) -> io::Result<()> { + w.write_all(b"")?; + match &n.description { + Some(desc) => self.render_inlines(desc, w)?, + None => write_escaped(&n.url, w)?, + } + w.write_all(b"") + } + + fn render_transclusion(&self, n: &TransclusionNode, w: &mut dyn Write) -> io::Result<()> { + w.write_all(b"\"")?; = n.attrs.keys().collect(); + keys.sort(); + for k in keys { + let v = &n.attrs[k]; + w.write_all(b" ")?; + write_escaped(k, w)?; + w.write_all(b"=\"")?; + write_escaped(v, w)?; + w.write_all(b"\"")?; + } + w.write_all(b">") + } + + fn render_raw_url(&self, n: &RawUrlNode, w: &mut dyn Write) -> io::Result<()> { + w.write_all(b"")?; + write_escaped(&n.url, w)?; + w.write_all(b"") + } +} + +// ===== Table colspan/rowspan layout ===== + +/// Per-cell render decision: emit a lead cell with the computed spans, +/// or skip (this cell is a continuation marker absorbed by its lead). +#[derive(Debug, Clone, Copy)] +enum CellLayout { + Lead { colspan: u32, rowspan: u32 }, + Skip, +} + +/// Walk every cell in the table and resolve `col_span` / `row_span` +/// markers into HTML `colspan="N"` / `rowspan="N"` attributes on the +/// preceding lead cell. +/// +/// Rules: +/// - `col_span: true` (`>` in vimwiki source) bumps the colspan of +/// the lead cell to its *left* in the same row. +/// - `row_span: true` (`\\/` in vimwiki source) bumps the rowspan +/// of the lead cell *above* in the same column. +/// - Markers that don't have a valid lead cell (first column / +/// first row) render as a plain empty cell so the document still +/// parses cleanly. +fn table_spans(table: &TableNode) -> Vec> { + let rows = table.rows.len(); + if rows == 0 { + return Vec::new(); + } + let cols = table.rows.iter().map(|r| r.cells.len()).max().unwrap_or(0); + let mut layout: Vec> = (0..rows) + .map(|_| { + (0..cols) + .map(|_| CellLayout::Lead { + colspan: 1, + rowspan: 1, + }) + .collect() + }) + .collect(); + for (ri, row) in table.rows.iter().enumerate() { + for (ci, cell) in row.cells.iter().enumerate() { + if cell.col_span { + // Find the lead cell to the left in this row (skipping + // already-skipped continuation slots) and bump it. + let mut k = ci; + while k > 0 { + k -= 1; + match layout[ri][k] { + CellLayout::Lead { + ref mut colspan, .. + } => { + *colspan += 1; + layout[ri][ci] = CellLayout::Skip; + break; + } + CellLayout::Skip => continue, + } + } + } else if cell.row_span { + // Walk up in the same column for the lead cell. + let mut k = ri; + while k > 0 { + k -= 1; + match layout[k][ci] { + CellLayout::Lead { + ref mut rowspan, .. + } => { + *rowspan += 1; + layout[ri][ci] = CellLayout::Skip; + break; + } + CellLayout::Skip => continue, + } + } + } + } + } + layout +} + +// ===== Default link resolver ===== + +/// Default `LinkResolver`. Mirrors the vimwiki link conventions: +/// wiki pages become `path.html`; interwiki links land in sibling +/// directories; diary entries land under `diary/`; file/local schemes +/// use their literal path; anchor-only links collapse to a fragment. +pub fn default_link_resolver(target: &LinkTarget) -> String { + if matches!(target.kind, LinkKind::AnchorOnly) { + return match &target.anchor { + Some(a) => format!("#{a}"), + None => String::new(), + }; + } + + let path = target.path.as_deref().unwrap_or(""); + let mut out = String::new(); + + match target.kind { + LinkKind::Wiki => { + if target.is_absolute { + out.push('/'); + } + out.push_str(path); + if target.is_directory { + out.push('/'); + } else if !path.is_empty() { + out.push_str(".html"); + } + } + LinkKind::Interwiki => { + if let Some(idx) = target.wiki_index { + out.push_str(&format!("../wiki{idx}/")); + } else if let Some(name) = &target.wiki_name { + out.push_str(&format!("../wn-{name}/")); + } + out.push_str(path); + if !target.is_directory { + out.push_str(".html"); + } + } + LinkKind::Diary => { + out.push_str("diary/"); + out.push_str(path); + out.push_str(".html"); + } + LinkKind::File => { + out.push_str(path); + } + LinkKind::Local => { + out.push_str("file://"); + if target.is_absolute && !path.starts_with('/') { + out.push('/'); + } + out.push_str(path); + } + LinkKind::Raw => { + out.push_str(path); + } + LinkKind::AnchorOnly => unreachable!(), + } + + if let Some(anchor) = &target.anchor { + out.push('#'); + out.push_str(anchor); + } + out +} + +// ===== HTML escaping ===== + +/// Escape `s` for HTML, but pass through inline tags whose name is in +/// `allowed` (vimwiki `valid_html_tags`) verbatim — mirroring upstream's +/// render-time `s:safe_html_line` regex rather than parsing raw HTML. +fn write_escaped_allowing(s: &str, allowed: &[String], w: &mut dyn Write) -> io::Result<()> { + let bytes = s.as_bytes(); + let mut i = 0; + let mut last = 0; + while i < bytes.len() { + if bytes[i] == b'<' { + if let Some(end) = match_allowed_tag(bytes, i, allowed) { + write_escaped(&s[last..i], w)?; + w.write_all(&bytes[i..end])?; + i = end; + last = end; + continue; + } + } + i += 1; + } + write_escaped(&s[last..], w) +} + +/// If `bytes[lt]` (`<`) begins a `` / `` whose name is in +/// `allowed`, return the index just past the closing `>`. Otherwise `None`. +fn match_allowed_tag(bytes: &[u8], lt: usize, allowed: &[String]) -> Option { + let mut p = lt + 1; + if bytes.get(p) == Some(&b'/') { + p += 1; + } + let name_begin = p; + while p < bytes.len() && (bytes[p] as char).is_ascii_alphabetic() { + p += 1; + } + if p == name_begin { + return None; + } + let name = std::str::from_utf8(&bytes[name_begin..p]).ok()?; + if !allowed.iter().any(|t| t.eq_ignore_ascii_case(name)) { + return None; + } + // Scan to the closing `>` (bail on a nested `<` — not a well-formed tag). + while p < bytes.len() && bytes[p] != b'>' { + if bytes[p] == b'<' { + return None; + } + p += 1; + } + if p >= bytes.len() { + return None; + } + Some(p + 1) +} + +/// Replace `:alias:` emoji shortcodes with their glyph (vimwiki +/// `emoji_enable`). Only a curated common subset is supported; unknown +/// shortcodes are left untouched. +fn substitute_emoji(s: &str) -> std::borrow::Cow<'_, str> { + if !s.contains(':') { + return std::borrow::Cow::Borrowed(s); + } + let mut out = String::with_capacity(s.len()); + let mut rest = s; + let mut changed = false; + while let Some(open) = rest.find(':') { + out.push_str(&rest[..open]); + let after = &rest[open + 1..]; + if let Some(close) = after.find(':') { + let alias = &after[..close]; + if !alias.is_empty() + && alias + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '+' || c == '-') + { + if let Some(glyph) = emoji_for(alias) { + out.push_str(glyph); + rest = &after[close + 1..]; + changed = true; + continue; + } + } + } + // Not a shortcode — keep the `:` and continue past it. + out.push(':'); + rest = after; + } + out.push_str(rest); + if changed { + std::borrow::Cow::Owned(out) + } else { + std::borrow::Cow::Borrowed(s) + } +} + +/// A curated subset of GitHub-style emoji shortcodes. +fn emoji_for(alias: &str) -> Option<&'static str> { + Some(match alias { + "smile" => "😄", + "smiley" => "😃", + "grin" => "😁", + "laughing" | "satisfied" => "😆", + "wink" => "😉", + "blush" => "😊", + "heart" => "❤️", + "broken_heart" => "💔", + "thumbsup" | "+1" => "👍", + "thumbsdown" | "-1" => "👎", + "ok_hand" => "👌", + "wave" => "👋", + "clap" => "👏", + "fire" => "🔥", + "star" => "⭐", + "sparkles" => "✨", + "zap" => "⚡", + "boom" => "💥", + "tada" => "🎉", + "rocket" => "🚀", + "bulb" => "💡", + "bug" => "🐛", + "warning" => "⚠️", + "white_check_mark" | "check" => "✅", + "x" => "❌", + "question" => "❓", + "exclamation" => "❗", + "eyes" => "👀", + "100" => "💯", + "pray" => "🙏", + "muscle" => "💪", + "coffee" => "☕", + "book" => "📖", + "memo" | "pencil" => "📝", + "lock" => "🔒", + "key" => "🔑", + "bell" => "🔔", + "hourglass" => "⌛", + "calendar" => "📅", + _ => return None, + }) +} + +fn write_escaped(s: &str, w: &mut dyn Write) -> io::Result<()> { + let bytes = s.as_bytes(); + let mut last = 0; + for (i, &b) in bytes.iter().enumerate() { + let replacement: &[u8] = match b { + b'<' => b"<", + b'>' => b">", + b'&' => b"&", + b'"' => b""", + b'\'' => b"'", + _ => continue, + }; + if i > last { + w.write_all(&bytes[last..i])?; + } + w.write_all(replacement)?; + last = i + 1; + } + if last < bytes.len() { + w.write_all(&bytes[last..])?; + } + Ok(()) +} + +fn escape(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '&' => out.push_str("&"), + '"' => out.push_str("""), + '\'' => out.push_str("'"), + c => out.push(c), + } + } + out +} diff --git a/crates/nuwiki-core/src/render/mod.rs b/crates/nuwiki-core/src/render/mod.rs new file mode 100644 index 0000000..d22520a --- /dev/null +++ b/crates/nuwiki-core/src/render/mod.rs @@ -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` 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 { + let mut buf = Vec::new(); + self.render(doc, &mut buf)?; + String::from_utf8(buf).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e)) + } +} diff --git a/crates/nuwiki-core/src/syntax/mod.rs b/crates/nuwiki-core/src/syntax/mod.rs new file mode 100644 index 0000000..39fc746 --- /dev/null +++ b/crates/nuwiki-core/src/syntax/mod.rs @@ -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 { + tokens: Vec, +} + +impl TokenStream { + pub fn new() -> Self { + Self { tokens: Vec::new() } + } + + pub fn from_vec(tokens: Vec) -> Self { + Self { tokens } + } + + pub fn into_vec(self) -> Vec { + 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 From> for TokenStream { + fn from(tokens: Vec) -> Self { + Self::from_vec(tokens) + } +} + +impl IntoIterator for TokenStream { + type Item = T; + type IntoIter = std::vec::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + self.tokens.into_iter() + } +} + +impl<'a, T> IntoIterator for &'a TokenStream { + 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; +} + +/// 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) -> 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; +} diff --git a/crates/nuwiki-core/src/syntax/registry.rs b/crates/nuwiki-core/src/syntax/registry.rs new file mode 100644 index 0000000..a8eeb92 --- /dev/null +++ b/crates/nuwiki-core/src/syntax/registry.rs @@ -0,0 +1,58 @@ +//! Registry of syntax plugins. +//! +//! Plugins are stored behind `Arc` 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>, +} + +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

    (&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 + '_ { + 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() + } +} diff --git a/crates/nuwiki-core/src/syntax/vimwiki/lexer.rs b/crates/nuwiki-core/src/syntax/vimwiki/lexer.rs new file mode 100644 index 0000000..c8b120d --- /dev/null +++ b/crates/nuwiki-core/src/syntax/vimwiki/lexer.rs @@ -0,0 +1,1259 @@ +//! Vimwiki lexer. +//! +//! Hand-rolled, two-pass: +//! +//! - **Block pass:** scan the source line by line, recognise structural +//! constructs (headings, lists, tables, fences, comments, etc.), emit +//! the matching block-level token, and within text-bearing lines invoke +//! the inline pass on the line content. +//! - **Inline pass:** scan a text run byte by byte, accumulate plain text, +//! and emit inline marker tokens (bold, italic, code, links, …). +//! +//! Both passes share one `Vec` so the parser sees a single, +//! ordered stream. The lexer is permissive: every delimiter is emitted; the +//! parser pairs them and falls back to literal text on mismatches. Spans are +//! stored as 0-indexed byte offsets. +//! +//! Multi-line constructs (`{{{ }}}`, `{{$ }}$`, `%%+ +%%`) flip a +//! `BlockMode` so subsequent lines are treated as raw content until the +//! matching closer is seen. + +use std::collections::HashMap; + +use crate::ast::{CheckboxState, Keyword, ListSymbol, Position, Span}; +use crate::listsyms::ListSyms; +use crate::syntax::{Lexer, TokenStream}; + +/// A single vimwiki token. The `kind` carries the variant, `span` carries +/// 0-indexed byte offsets into the source. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VimwikiToken { + pub kind: VimwikiTokenKind, + pub span: Span, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum VimwikiTokenKind { + // ----- Line structure ----- + /// End of a non-empty line (`\n`). + Newline, + /// A line that contained only whitespace. Replaces both the (empty) + /// content and the trailing newline. + BlankLine, + + // ----- Headings ----- + /// Opening `=`s of a heading. `centered` is true when the line had + /// leading whitespace before the `=` run. + HeadingOpen { + level: u8, + centered: bool, + }, + /// Closing `=`s on the same line as `HeadingOpen`. + HeadingClose, + + // ----- Bare structure ----- + HorizontalRule, + BlockquoteMarker, + BlockquoteIndent, + + // ----- Lists ----- + ListMarker { + symbol: ListSymbol, + indent: u32, + }, + Checkbox(CheckboxState), + + // ----- Definition lists ----- + DefinitionTermMarker, + + // ----- Tables ----- + /// `|` cell separator. + TableSep, + /// A header-separator row, e.g. `|---|---|`. Carries per-cell + /// Markdown-style alignment markers (`:--`, `--:`, `:--:`). + TableHeaderRow(Vec), + /// A cell whose only content is `>`, meaning "merge with cell to the left". + TableColSpan, + /// A cell whose only content is `\/`, meaning "merge with cell above". + TableRowSpan, + + // ----- Multi-line fences ----- + PreformattedOpen { + language: Option, + attrs: HashMap, + }, + PreformattedClose, + PreformattedLine(String), + + MathBlockOpen { + environment: Option, + }, + MathBlockClose, + MathBlockLine(String), + + /// Single-line comment: `%% ...` + CommentLine(String), + /// Opening of a multi-line comment: `%%+` + CommentMultiOpen, + CommentMultiClose, + CommentMultiLine(String), + + // ----- Tags ----- + /// `:tag1:tag2:` — colon-delimited tag line. The lexer treats this as + /// block-level (must be the entire trimmed content of the line). + /// Scope (file / heading / standalone) is the parser's job. + Tag(Vec), + + // ----- Page placeholders ----- + PlaceholderTitle(Option), + PlaceholderNohtml, + PlaceholderTemplate(Option), + PlaceholderDate(Option), + + // ----- Inline content ----- + Text(String), + BoldDelim, + ItalicDelim, + StrikethroughDelim, + SuperscriptDelim, + SubscriptDelim, + Code(String), + MathInline(String), + Keyword(Keyword), + + // ----- Links / transclusions ----- + WikiLinkOpen, + WikiLinkClose, + WikiLinkSep, + TransclusionOpen, + TransclusionClose, + TransclusionSep, + RawUrl(String), + + /// Lex error — never aborts the whole document. + Error(String), +} + +#[derive(Debug, Default, Clone)] +pub struct VimwikiLexer { + listsyms: ListSyms, +} + +impl VimwikiLexer { + pub fn new() -> Self { + Self::default() + } + + /// Recognise checkbox glyphs from a custom palette + /// (vimwiki's `g:vimwiki_listsyms`) instead of the default `" .oOX"`. + pub fn with_listsyms(mut self, listsyms: ListSyms) -> Self { + self.listsyms = listsyms; + self + } +} + +impl Lexer for VimwikiLexer { + type Token = VimwikiToken; + + fn lex(&self, text: &str) -> TokenStream { + let mut state = LexState::new(text, &self.listsyms); + state.run(); + TokenStream::from_vec(state.tokens) + } +} + +// ===== Internal state machine ===== + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum BlockMode { + Normal, + Preformatted, + MathBlock, + MultilineComment, +} + +struct LexState<'src> { + src: &'src str, + /// Byte offset of the start of the current line. + line_start_offset: usize, + /// Index of the current line (0-based). + line: u32, + tokens: Vec, + mode: BlockMode, + listsyms: &'src ListSyms, +} + +impl<'src> LexState<'src> { + fn new(src: &'src str, listsyms: &'src ListSyms) -> Self { + Self { + src, + line_start_offset: 0, + line: 0, + tokens: Vec::new(), + mode: BlockMode::Normal, + listsyms, + } + } + + fn run(&mut self) { + while self.line_start_offset < self.src.len() { + let line_text = self.current_line(); + let line_len = line_text.len(); + + match self.mode { + BlockMode::Normal => self.lex_normal_line(line_text), + BlockMode::Preformatted => self.lex_preformatted_line(line_text), + BlockMode::MathBlock => self.lex_math_block_line(line_text), + BlockMode::MultilineComment => self.lex_multi_comment_line(line_text), + } + + // Skip past the line's trailing newline (if any) and advance line counters. + let after_content = self.line_start_offset + line_len; + if after_content < self.src.len() && self.src.as_bytes()[after_content] == b'\n' { + self.line_start_offset = after_content + 1; + self.line += 1; + } else { + self.line_start_offset = after_content; + } + } + } + + /// Slice of the current line, not including the trailing newline. + fn current_line(&self) -> &'src str { + let rest = &self.src[self.line_start_offset..]; + match rest.find('\n') { + Some(idx) => &rest[..idx], + None => rest, + } + } + + /// Position at byte offset `col` within the current line. + fn pos_in_line(&self, col: u32) -> Position { + Position { + line: self.line, + column: col, + offset: self.line_start_offset + col as usize, + } + } + + fn line_start_pos(&self) -> Position { + self.pos_in_line(0) + } + + fn line_end_pos(&self, line_text: &str) -> Position { + self.pos_in_line(line_text.len() as u32) + } + + fn push(&mut self, kind: VimwikiTokenKind, span: Span) { + self.tokens.push(VimwikiToken { kind, span }); + } + + /// Emit a token whose span runs from byte-col `start_col` to `end_col` + /// within the current line. + fn emit(&mut self, kind: VimwikiTokenKind, start_col: u32, end_col: u32) { + let span = Span::new(self.pos_in_line(start_col), self.pos_in_line(end_col)); + self.push(kind, span); + } + + fn emit_newline(&mut self, line_text: &str) { + let nl_col = line_text.len() as u32; + // Newline is just \n at column = line_text.len() spanning one byte. + let span = Span::new(self.pos_in_line(nl_col), self.pos_in_line(nl_col + 1)); + self.push(VimwikiTokenKind::Newline, span); + } + + // ===== Mode dispatch ===== + + fn lex_preformatted_line(&mut self, line: &str) { + // Closing fence: line whose first non-space content is `}}}`. + if line.trim() == "}}}" { + let start_col = line.find("}}}").unwrap() as u32; + self.emit( + VimwikiTokenKind::PreformattedClose, + start_col, + start_col + 3, + ); + self.mode = BlockMode::Normal; + self.emit_newline(line); + return; + } + let span = Span::new(self.line_start_pos(), self.line_end_pos(line)); + self.push(VimwikiTokenKind::PreformattedLine(line.to_owned()), span); + self.emit_newline(line); + } + + fn lex_math_block_line(&mut self, line: &str) { + if line.trim() == "}}$" { + let start_col = line.find("}}$").unwrap() as u32; + self.emit(VimwikiTokenKind::MathBlockClose, start_col, start_col + 3); + self.mode = BlockMode::Normal; + self.emit_newline(line); + return; + } + let span = Span::new(self.line_start_pos(), self.line_end_pos(line)); + self.push(VimwikiTokenKind::MathBlockLine(line.to_owned()), span); + self.emit_newline(line); + } + + fn lex_multi_comment_line(&mut self, line: &str) { + // Closer is `+%%`, possibly with content before it on the same line. + if let Some(idx) = line.find("+%%") { + let prefix = &line[..idx]; + if !prefix.is_empty() { + let span = Span::new(self.pos_in_line(0), self.pos_in_line(prefix.len() as u32)); + self.push(VimwikiTokenKind::CommentMultiLine(prefix.to_owned()), span); + } + let close_col = idx as u32; + self.emit( + VimwikiTokenKind::CommentMultiClose, + close_col, + close_col + 3, + ); + self.mode = BlockMode::Normal; + self.emit_newline(line); + return; + } + let span = Span::new(self.line_start_pos(), self.line_end_pos(line)); + self.push(VimwikiTokenKind::CommentMultiLine(line.to_owned()), span); + self.emit_newline(line); + } + + fn lex_normal_line(&mut self, line: &str) { + // Whitespace-only line → BlankLine (replaces both content and the + // following newline). + if line.bytes().all(|b| b == b' ' || b == b'\t') { + let span = Span::new(self.line_start_pos(), self.line_end_pos(line)); + self.push(VimwikiTokenKind::BlankLine, span); + // Also account for the newline byte if present. + // We don't emit a Newline because BlankLine subsumes both. + return; + } + + // Block patterns in priority order. The first match wins. + if self.try_lex_multi_comment_open(line) + || self.try_lex_single_comment(line) + || self.try_lex_preformatted_open(line) + || self.try_lex_math_block_open(line) + || self.try_lex_placeholder(line) + || self.try_lex_horizontal_rule(line) + || self.try_lex_heading(line) + || self.try_lex_tag(line) + || self.try_lex_table_row(line) + || self.try_lex_blockquote(line) + || self.try_lex_list_item(line) + || self.try_lex_definition_term(line) + { + return; + } + + // Default: paragraph line — lex inline. + self.lex_inline(line, 0, line.len() as u32); + self.emit_newline(line); + } + + // ===== Block patterns ===== + + fn try_lex_multi_comment_open(&mut self, line: &str) -> bool { + let trimmed = line.trim_start(); + if !trimmed.starts_with("%%+") { + return false; + } + let indent = (line.len() - trimmed.len()) as u32; + self.emit(VimwikiTokenKind::CommentMultiOpen, indent, indent + 3); + // Same line might also contain `+%%` (one-line multi). + let after_open = (indent + 3) as usize; + let rest = &line[after_open..]; + if let Some(end_rel) = rest.find("+%%") { + let prefix = &rest[..end_rel]; + if !prefix.is_empty() { + let p_start = indent + 3; + let p_end = p_start + prefix.len() as u32; + self.push( + VimwikiTokenKind::CommentMultiLine(prefix.to_owned()), + Span::new(self.pos_in_line(p_start), self.pos_in_line(p_end)), + ); + } + let close_col = (after_open + end_rel) as u32; + self.emit( + VimwikiTokenKind::CommentMultiClose, + close_col, + close_col + 3, + ); + self.emit_newline(line); + } else { + // Capture remainder of this line as content, then enter + // multiline-comment mode for following lines. + if !rest.is_empty() { + let p_start = indent + 3; + let p_end = p_start + rest.len() as u32; + self.push( + VimwikiTokenKind::CommentMultiLine(rest.to_owned()), + Span::new(self.pos_in_line(p_start), self.pos_in_line(p_end)), + ); + } + self.emit_newline(line); + self.mode = BlockMode::MultilineComment; + } + true + } + + fn try_lex_single_comment(&mut self, line: &str) -> bool { + let trimmed = line.trim_start(); + if !trimmed.starts_with("%%") || trimmed.starts_with("%%+") { + return false; + } + let indent = (line.len() - trimmed.len()) as u32; + let content = &trimmed[2..]; // skip "%%" + let span = Span::new(self.pos_in_line(indent), self.line_end_pos(line)); + self.push(VimwikiTokenKind::CommentLine(content.to_owned()), span); + self.emit_newline(line); + true + } + + fn try_lex_preformatted_open(&mut self, line: &str) -> bool { + let trimmed = line.trim_start(); + if !trimmed.starts_with("{{{") { + return false; + } + let indent = (line.len() - trimmed.len()) as u32; + // `{{{[lang][;key=val[;key=val]*]?` style. Vimwiki accepts a single + // word after the fence (language hint) and/or class/attribute key=val + // pairs separated by spaces. Keep parsing forgiving. + let after = trimmed[3..].trim(); + let (language, attrs) = parse_fence_attrs(after); + let kind = VimwikiTokenKind::PreformattedOpen { language, attrs }; + let span = Span::new(self.pos_in_line(indent), self.line_end_pos(line)); + self.push(kind, span); + self.mode = BlockMode::Preformatted; + self.emit_newline(line); + true + } + + fn try_lex_math_block_open(&mut self, line: &str) -> bool { + let trimmed = line.trim_start(); + if !trimmed.starts_with("{{$") { + return false; + } + let indent = (line.len() - trimmed.len()) as u32; + let after = trimmed[3..].trim(); + // `{{$%env%` — environment is between `%`s. + let environment = after + .strip_prefix('%') + .and_then(|s| s.strip_suffix('%')) + .map(str::to_owned); + let span = Span::new(self.pos_in_line(indent), self.line_end_pos(line)); + self.push(VimwikiTokenKind::MathBlockOpen { environment }, span); + self.mode = BlockMode::MathBlock; + self.emit_newline(line); + true + } + + fn try_lex_placeholder(&mut self, line: &str) -> bool { + // Placeholders must start at column 0 (no leading whitespace). + if !line.starts_with('%') { + return false; + } + let kind = if let Some(rest) = line.strip_prefix("%title") { + let value = rest.trim(); + let v = if value.is_empty() { + None + } else { + Some(value.to_owned()) + }; + VimwikiTokenKind::PlaceholderTitle(v) + } else if line.trim_end() == "%nohtml" { + VimwikiTokenKind::PlaceholderNohtml + } else if let Some(rest) = line.strip_prefix("%template") { + let value = rest.trim(); + let v = if value.is_empty() { + None + } else { + Some(value.to_owned()) + }; + VimwikiTokenKind::PlaceholderTemplate(v) + } else if let Some(rest) = line.strip_prefix("%date") { + let value = rest.trim(); + let v = if value.is_empty() { + None + } else { + Some(value.to_owned()) + }; + VimwikiTokenKind::PlaceholderDate(v) + } else { + return false; + }; + let span = Span::new(self.line_start_pos(), self.line_end_pos(line)); + self.push(kind, span); + self.emit_newline(line); + true + } + + fn try_lex_horizontal_rule(&mut self, line: &str) -> bool { + let trimmed = line.trim(); + if trimmed.len() < 4 || !trimmed.bytes().all(|b| b == b'-') { + return false; + } + // Four or more dashes. Allow surrounding whitespace. + let span = Span::new(self.line_start_pos(), self.line_end_pos(line)); + self.push(VimwikiTokenKind::HorizontalRule, span); + self.emit_newline(line); + true + } + + fn try_lex_heading(&mut self, line: &str) -> bool { + let bytes = line.as_bytes(); + let leading_ws = bytes + .iter() + .take_while(|&&b| b == b' ' || b == b'\t') + .count(); + let centered = leading_ws > 0; + + // Count `=`s after leading whitespace. + let mut level = 0usize; + while level < 6 && leading_ws + level < bytes.len() && bytes[leading_ws + level] == b'=' { + level += 1; + } + if level == 0 { + return false; + } + // Must be followed by at least one space. + if leading_ws + level >= bytes.len() || bytes[leading_ws + level] != b' ' { + return false; + } + + // Trailing `=`s of the same level. + let trimmed_end = line.trim_end_matches([' ', '\t']); + let trailing_eqs = trimmed_end + .as_bytes() + .iter() + .rev() + .take_while(|&&b| b == b'=') + .count(); + if trailing_eqs != level { + return false; + } + + let title_start = leading_ws + level; + let title_end = trimmed_end.len() - trailing_eqs; + if title_end <= title_start { + return false; + } + + let open_col = leading_ws as u32; + let open_end = (leading_ws + level) as u32; + self.emit( + VimwikiTokenKind::HeadingOpen { + level: level as u8, + centered, + }, + open_col, + open_end, + ); + + // Inline content between markers (trim one space on each side if present). + let mut t_start = title_start; + let mut t_end = title_end; + if t_start < t_end && bytes[t_start] == b' ' { + t_start += 1; + } + if t_end > t_start && bytes[t_end - 1] == b' ' { + t_end -= 1; + } + if t_end > t_start { + self.lex_inline(&line[t_start..t_end], t_start as u32, t_end as u32); + } + + let close_col = title_end as u32; + let close_end = (title_end + trailing_eqs) as u32; + self.emit(VimwikiTokenKind::HeadingClose, close_col, close_end); + self.emit_newline(line); + true + } + + fn try_lex_table_row(&mut self, line: &str) -> bool { + let trimmed = line.trim(); + if !trimmed.starts_with('|') || !trimmed.ends_with('|') || trimmed.len() < 2 { + return false; + } + + // Header separator row: every cell is `-`s (with optional `:` + // anchors at one or both ends for alignment) or whitespace. + if trimmed + .split('|') + .filter(|s| !s.is_empty()) + .all(is_sep_cell) + { + let aligns: Vec = trimmed + .split('|') + .filter(|s| !s.is_empty()) + .map(parse_sep_alignment) + .collect(); + let span = Span::new(self.line_start_pos(), self.line_end_pos(line)); + self.push(VimwikiTokenKind::TableHeaderRow(aligns), span); + self.emit_newline(line); + return true; + } + + // Walk the line and emit TableSep tokens at each `|`, with cell + // content lexed inline between them. + let bytes = line.as_bytes(); + let mut i = 0usize; + // Skip leading whitespace. + while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') { + i += 1; + } + // Trailing whitespace bound. + let mut end = bytes.len(); + while end > i && (bytes[end - 1] == b' ' || bytes[end - 1] == b'\t') { + end -= 1; + } + + // Collect cell boundaries. + let mut sep_positions = Vec::new(); + let mut k = i; + while k < end { + if bytes[k] == b'|' { + sep_positions.push(k); + } + k += 1; + } + + if sep_positions.len() < 2 { + return false; + } + + for window in sep_positions.windows(2) { + let sep_col = window[0] as u32; + self.emit(VimwikiTokenKind::TableSep, sep_col, sep_col + 1); + let cell_start = window[0] + 1; + let cell_end = window[1]; + let cell_text = &line[cell_start..cell_end]; + let cell_trimmed = cell_text.trim(); + match cell_trimmed { + ">" => { + let s = (cell_start + cell_text.find('>').unwrap()) as u32; + self.emit(VimwikiTokenKind::TableColSpan, s, s + 1); + } + "\\/" => { + let s = (cell_start + cell_text.find("\\/").unwrap()) as u32; + self.emit(VimwikiTokenKind::TableRowSpan, s, s + 2); + } + _ if !cell_trimmed.is_empty() => { + self.lex_inline(cell_text, cell_start as u32, cell_end as u32); + } + _ => {} + } + } + // Emit the last separator. + let last_sep = *sep_positions.last().unwrap() as u32; + self.emit(VimwikiTokenKind::TableSep, last_sep, last_sep + 1); + self.emit_newline(line); + true + } + + fn try_lex_blockquote(&mut self, line: &str) -> bool { + // `>` prefix style. + let trimmed = line.trim_start(); + let indent = (line.len() - trimmed.len()) as u32; + if let Some(rest) = + trimmed + .strip_prefix("> ") + .or_else(|| if trimmed == ">" { Some("") } else { None }) + { + self.emit(VimwikiTokenKind::BlockquoteMarker, indent, indent + 1); + if !rest.is_empty() { + let inline_start = indent + 2; + let inline_end = inline_start + rest.len() as u32; + self.lex_inline(rest, inline_start, inline_end); + } + self.emit_newline(line); + return true; + } + + // 4-space indent style. To avoid swallowing list items, only treat as + // a blockquote when the indented content does NOT start with a list + // marker. + if let Some(rest) = line.strip_prefix(" ") { + if rest.chars().next().is_none_or(|c| c == ' ' || c == '\t') + || looks_like_list_marker(rest) + { + return false; + } + self.emit(VimwikiTokenKind::BlockquoteIndent, 0, 4); + self.lex_inline(rest, 4, line.len() as u32); + self.emit_newline(line); + return true; + } + false + } + + fn try_lex_list_item(&mut self, line: &str) -> bool { + let trimmed = line.trim_start(); + let indent_bytes = line.len() - trimmed.len(); + let indent = indent_bytes as u32; + + let (symbol, marker_len) = match list_marker_at(trimmed) { + Some(v) => v, + None => return false, + }; + + // Marker must be followed by a space. + if marker_len >= trimmed.len() || trimmed.as_bytes()[marker_len] != b' ' { + return false; + } + + let marker_start = indent; + let marker_end = indent + marker_len as u32; + self.emit( + VimwikiTokenKind::ListMarker { symbol, indent }, + marker_start, + marker_end, + ); + + // Past marker + the single required space. + let mut cursor = marker_len + 1; + let after = &trimmed[cursor..]; + + // Optional checkbox `[ ]` / `[.]` / `[o]` / `[O]` / `[X]` / `[-]`, + // followed by a space. + if let Some((cb, cb_len)) = checkbox_at(after, self.listsyms) { + let cb_start = indent + cursor as u32; + let cb_end = cb_start + cb_len as u32; + self.emit(VimwikiTokenKind::Checkbox(cb), cb_start, cb_end); + cursor += cb_len; + // Eat the trailing space if present. + if cursor < trimmed.len() && trimmed.as_bytes()[cursor] == b' ' { + cursor += 1; + } + } + + let inline_start = indent + cursor as u32; + let inline_end = line.len() as u32; + if (inline_end as usize) > (inline_start as usize) { + let abs_start = self.line_start_offset + inline_start as usize; + let abs_end = self.line_start_offset + inline_end as usize; + let slice = &self.src[abs_start..abs_end]; + self.lex_inline(slice, inline_start, inline_end); + } + self.emit_newline(line); + true + } + + /// `:tag1:tag2:tag3:` — colon-delimited tag line (block-level). + /// + /// The whole trimmed line must match: starts and ends with `:`, with + /// non-empty whitespace-free names between adjacent colons. Empty + /// segments or whitespace inside tag names disqualify the line. + /// Doesn't conflict with `::` (definition list marker) because that + /// pattern requires text *before* the `::`. + fn try_lex_tag(&mut self, line: &str) -> bool { + let trimmed = line.trim(); + if trimmed.len() < 3 || !trimmed.starts_with(':') || !trimmed.ends_with(':') { + return false; + } + // `:tag1:tag2:` splits as `["", "tag1", "tag2", ""]`; the empty + // strings at the ends are expected, everything in between must be + // non-empty and contain no whitespace. + let parts: Vec<&str> = trimmed.split(':').collect(); + if parts.first() != Some(&"") || parts.last() != Some(&"") { + return false; + } + let names = &parts[1..parts.len() - 1]; + if names.is_empty() + || names + .iter() + .any(|t| t.is_empty() || t.chars().any(char::is_whitespace)) + { + return false; + } + let indent = (line.len() - line.trim_start().len()) as u32; + let end_col = indent + trimmed.len() as u32; + self.emit( + VimwikiTokenKind::Tag(names.iter().map(|s| (*s).to_owned()).collect()), + indent, + end_col, + ); + self.emit_newline(line); + true + } + + fn try_lex_definition_term(&mut self, line: &str) -> bool { + // `Term:: Definition` or `Term::` (continuation lines follow). + // The `::` must be preceded by non-`:` text and followed by either + // EOL or a single space + content. + let bytes = line.as_bytes(); + let mut i = 0usize; + let mut found = None; + while i + 1 < bytes.len() { + if bytes[i] == b':' && bytes[i + 1] == b':' { + // Must not be preceded by another `:` (avoid `:::`). + if i > 0 && bytes[i - 1] == b':' { + i += 1; + continue; + } + // Must not be followed by another `:`. + if i + 2 < bytes.len() && bytes[i + 2] == b':' { + i += 1; + continue; + } + found = Some(i); + break; + } + i += 1; + } + let split = match found { + Some(s) if s > 0 => s, + _ => return false, + }; + + // Term inline. + self.lex_inline(&line[..split], 0, split as u32); + let marker_col = split as u32; + self.emit( + VimwikiTokenKind::DefinitionTermMarker, + marker_col, + marker_col + 2, + ); + + let after = &line[split + 2..]; + if !after.is_empty() { + let leading = after + .bytes() + .take_while(|b| *b == b' ' || *b == b'\t') + .count(); + if leading > 0 { + // Treat the post-`::` whitespace as a separator; lex the rest as inline. + let inline_start = (split + 2 + leading) as u32; + let inline_end = line.len() as u32; + if inline_end > inline_start { + self.lex_inline(&after[leading..], inline_start, inline_end); + } + } else { + // No space after `::`: still try to lex inline. + let inline_start = (split + 2) as u32; + self.lex_inline(after, inline_start, line.len() as u32); + } + } + self.emit_newline(line); + true + } + + // ===== Inline pass ===== + // + // Inline content lives at byte offsets [start_col, end_col) within the + // current line. Span columns are relative to the line. + + fn lex_inline(&mut self, slice: &str, line_col_start: u32, _line_col_end: u32) { + let bytes = slice.as_bytes(); + let mut buf = String::new(); + let mut buf_start: Option = None; + let mut i = 0usize; + + let flush = |this: &mut Self, buf: &mut String, buf_start: &mut Option, end: u32| { + if !buf.is_empty() { + let s = buf_start.take().unwrap(); + this.emit(VimwikiTokenKind::Text(std::mem::take(buf)), s, end); + } + }; + + while i < bytes.len() { + let abs_col = line_col_start + i as u32; + let rest = &slice[i..]; + + // 1) Multi-char delimiters and constructs (longest match wins). + if let Some(after_open) = rest.strip_prefix("[[") { + flush(self, &mut buf, &mut buf_start, abs_col); + self.emit(VimwikiTokenKind::WikiLinkOpen, abs_col, abs_col + 2); + i += 2; + // Lex until `]]` (absolute index into `slice`). + let close_abs_in_slice = after_open.find("]]").map(|c| i + c); + let inner_end = close_abs_in_slice.unwrap_or(slice.len()); + self.lex_link_body(slice, i, line_col_start, inner_end, true); + i = inner_end; + if close_abs_in_slice.is_some() { + let close_col = line_col_start + i as u32; + self.emit(VimwikiTokenKind::WikiLinkClose, close_col, close_col + 2); + i += 2; + } + continue; + } + if rest.starts_with("{{{") { + // Inline {{{ is rare; fall through as text. + buf_start.get_or_insert(abs_col); + buf.push('{'); + i += 1; + continue; + } + if rest.starts_with("{{") { + flush(self, &mut buf, &mut buf_start, abs_col); + self.emit(VimwikiTokenKind::TransclusionOpen, abs_col, abs_col + 2); + i += 2; + let close_rel = slice[i..].find("}}"); + let inner_end_rel = close_rel.map(|c| i + c).unwrap_or(slice.len()); + self.lex_link_body(slice, i, line_col_start, inner_end_rel, false); + i = inner_end_rel; + if close_rel.is_some() { + let close_col = line_col_start + i as u32; + self.emit( + VimwikiTokenKind::TransclusionClose, + close_col, + close_col + 2, + ); + i += 2; + } + continue; + } + if rest.starts_with("~~") { + flush(self, &mut buf, &mut buf_start, abs_col); + self.emit(VimwikiTokenKind::StrikethroughDelim, abs_col, abs_col + 2); + i += 2; + continue; + } + if rest.starts_with(",,") { + flush(self, &mut buf, &mut buf_start, abs_col); + self.emit(VimwikiTokenKind::SubscriptDelim, abs_col, abs_col + 2); + i += 2; + continue; + } + + // 2) Inline code: `...` + if bytes[i] == b'`' { + if let Some(end_rel) = slice[i + 1..].find('`') { + flush(self, &mut buf, &mut buf_start, abs_col); + let content = &slice[i + 1..i + 1 + end_rel]; + let span_end = abs_col + (end_rel + 2) as u32; + self.emit( + VimwikiTokenKind::Code(content.to_owned()), + abs_col, + span_end, + ); + i += end_rel + 2; + continue; + } + } + + // 3) Inline math: $...$ (single $ on each side; bail to text if unmatched) + if bytes[i] == b'$' { + if let Some(end_rel) = slice[i + 1..].find('$') { + flush(self, &mut buf, &mut buf_start, abs_col); + let content = &slice[i + 1..i + 1 + end_rel]; + let span_end = abs_col + (end_rel + 2) as u32; + self.emit( + VimwikiTokenKind::MathInline(content.to_owned()), + abs_col, + span_end, + ); + i += end_rel + 2; + continue; + } + } + + // 4) Single-char delimiters. + let single = match bytes[i] { + b'*' => Some(VimwikiTokenKind::BoldDelim), + b'_' => Some(VimwikiTokenKind::ItalicDelim), + b'^' => Some(VimwikiTokenKind::SuperscriptDelim), + _ => None, + }; + if let Some(kind) = single { + flush(self, &mut buf, &mut buf_start, abs_col); + self.emit(kind, abs_col, abs_col + 1); + i += 1; + continue; + } + + // 5) Raw URL — at a word boundary. + if is_word_boundary(bytes, i) { + if let Some(url_len) = match_url(rest) { + flush(self, &mut buf, &mut buf_start, abs_col); + let url = &rest[..url_len]; + self.emit( + VimwikiTokenKind::RawUrl(url.to_owned()), + abs_col, + abs_col + url_len as u32, + ); + i += url_len; + continue; + } + // 6) Keyword. + if let Some((kw_len, kw)) = match_keyword(rest) { + flush(self, &mut buf, &mut buf_start, abs_col); + self.emit( + VimwikiTokenKind::Keyword(kw), + abs_col, + abs_col + kw_len as u32, + ); + i += kw_len; + continue; + } + } + + // 7) Default: accumulate into Text. Walk to next byte boundary. + let ch = slice[i..].chars().next().unwrap(); + let ch_len = ch.len_utf8(); + buf_start.get_or_insert(abs_col); + buf.push(ch); + i += ch_len; + } + + let end_col = line_col_start + slice.len() as u32; + flush(self, &mut buf, &mut buf_start, end_col); + } + + /// Lex inline content within `[[...]]` or `{{...}}`, where `|` is a + /// separator rather than literal pipe. `is_wikilink` chooses the token + /// variant for the separator. + fn lex_link_body( + &mut self, + slice: &str, + start: usize, + line_col_start: u32, + end: usize, + is_wikilink: bool, + ) { + let bytes = slice.as_bytes(); + let mut buf = String::new(); + let mut buf_start: Option = None; + let mut i = start; + + let flush = + |this: &mut Self, buf: &mut String, buf_start: &mut Option, end_col: u32| { + if !buf.is_empty() { + let s = buf_start.take().unwrap(); + this.emit(VimwikiTokenKind::Text(std::mem::take(buf)), s, end_col); + } + }; + + while i < end { + let abs_col = line_col_start + i as u32; + if bytes[i] == b'|' { + flush(self, &mut buf, &mut buf_start, abs_col); + let kind = if is_wikilink { + VimwikiTokenKind::WikiLinkSep + } else { + VimwikiTokenKind::TransclusionSep + }; + self.emit(kind, abs_col, abs_col + 1); + i += 1; + continue; + } + let ch = slice[i..].chars().next().unwrap(); + let ch_len = ch.len_utf8(); + buf_start.get_or_insert(abs_col); + buf.push(ch); + i += ch_len; + } + flush(self, &mut buf, &mut buf_start, line_col_start + end as u32); + } +} + +// ===== Helpers ===== + +/// Parse the bit after `{{{` on a fence line. Format is forgiving: +/// `lang` (single word) followed by optional `key=value` pairs separated +/// by spaces. +fn parse_fence_attrs(s: &str) -> (Option, HashMap) { + let mut attrs = HashMap::new(); + let mut language: Option = None; + for tok in s.split_whitespace() { + if let Some(eq) = tok.find('=') { + let (k, v) = tok.split_at(eq); + let v = &v[1..]; + // Strip optional surrounding quotes from value. + let v = v.trim_matches('"').trim_matches('\''); + attrs.insert(k.to_owned(), v.to_owned()); + } else if language.is_none() { + language = Some(tok.to_owned()); + } + } + (language, attrs) +} + +fn is_word_boundary(bytes: &[u8], i: usize) -> bool { + if i == 0 { + return true; + } + let prev = bytes[i - 1]; + !(prev.is_ascii_alphanumeric() || prev == b'_') +} + +fn is_word_end(bytes: &[u8], i: usize) -> bool { + if i >= bytes.len() { + return true; + } + let next = bytes[i]; + !(next.is_ascii_alphanumeric() || next == b'_') +} + +const URL_SCHEMES: &[&str] = &["https://", "http://", "ftp://", "mailto:", "file://"]; + +fn match_url(rest: &str) -> Option { + let bytes = rest.as_bytes(); + let scheme_len = URL_SCHEMES.iter().find_map(|s| { + if rest.starts_with(s) { + Some(s.len()) + } else { + None + } + })?; + let mut end = scheme_len; + while end < bytes.len() { + let b = bytes[end]; + // Conservative URL char set; stops at whitespace and most punctuation + // that's typically not part of a URL tail. + let ok = b.is_ascii_alphanumeric() + || matches!( + b, + b'-' | b'_' + | b'.' + | b'~' + | b'/' + | b':' + | b'?' + | b'#' + | b'[' + | b']' + | b'@' + | b'!' + | b'$' + | b'&' + | b'\'' + | b'(' + | b')' + | b'*' + | b'+' + | b',' + | b';' + | b'=' + | b'%' + ); + if !ok { + break; + } + end += 1; + } + // Trim trailing punctuation that's almost always sentence punctuation. + while end > scheme_len { + match bytes[end - 1] { + b'.' | b',' | b';' | b':' | b'!' | b'?' | b')' | b']' => end -= 1, + _ => break, + } + } + if end <= scheme_len { + None + } else { + Some(end) + } +} + +const KEYWORDS: &[(&str, Keyword)] = &[ + ("TODO", Keyword::Todo), + ("DONE", Keyword::Done), + ("STARTED", Keyword::Started), + ("FIXME", Keyword::Fixme), + ("FIXED", Keyword::Fixed), + ("XXX", Keyword::Xxx), + ("STOPPED", Keyword::Stopped), +]; + +fn match_keyword(rest: &str) -> Option<(usize, Keyword)> { + let bytes = rest.as_bytes(); + KEYWORDS.iter().find_map(|(text, kw)| { + if rest.starts_with(text) && is_word_end(bytes, text.len()) { + Some((text.len(), *kw)) + } else { + None + } + }) +} + +fn list_marker_at(s: &str) -> Option<(ListSymbol, usize)> { + let bytes = s.as_bytes(); + if bytes.is_empty() { + return None; + } + // Single-char markers: -, *, # + match bytes[0] { + b'-' => return Some((ListSymbol::Dash, 1)), + b'*' => return Some((ListSymbol::Star, 1)), + b'#' => return Some((ListSymbol::Hash, 1)), + _ => {} + } + // Numeric: `1.` or `1)`. Allow multi-digit. + let digits = bytes.iter().take_while(|b| b.is_ascii_digit()).count(); + if digits > 0 && bytes.len() > digits { + match bytes[digits] { + b'.' => return Some((ListSymbol::Numeric, digits + 1)), + b')' => return Some((ListSymbol::NumericParen, digits + 1)), + _ => {} + } + } + // Single-letter alpha or roman: `a)`, `A)`, `i)`, `I)`. + if bytes.len() >= 2 && bytes[1] == b')' { + let c = bytes[0]; + if c.is_ascii_lowercase() { + // `i)` / `v)` / `x)` are roman by convention; without context we + // treat any single lowercase letter as alpha. Roman vs alpha is + // ambiguous from a single char; vimwiki itself relies on context. + // Mark `i` as RomanParen as a small concession to the spec. + let sym = if c == b'i' { + ListSymbol::RomanParen + } else { + ListSymbol::AlphaParen + }; + return Some((sym, 2)); + } + if c.is_ascii_uppercase() { + let sym = if c == b'I' { + ListSymbol::RomanUpperParen + } else { + ListSymbol::AlphaUpperParen + }; + return Some((sym, 2)); + } + } + None +} + +fn looks_like_list_marker(s: &str) -> bool { + list_marker_at(s).is_some_and(|(_, len)| s.as_bytes().get(len) == Some(&b' ')) +} + +/// Is this cell content a valid separator-cell body? Allows leading / +/// trailing `:` for alignment and inner runs of `-` plus whitespace. +fn is_sep_cell(cell: &str) -> bool { + let t = cell.trim(); + if t.is_empty() { + return false; + } + let body = t.trim_start_matches(':').trim_end_matches(':'); + !body.is_empty() && body.chars().all(|c| c == '-') +} + +fn parse_sep_alignment(cell: &str) -> crate::ast::TableAlign { + let t = cell.trim(); + let l = t.starts_with(':'); + let r = t.ends_with(':'); + match (l, r) { + (true, true) => crate::ast::TableAlign::Center, + (true, false) => crate::ast::TableAlign::Left, + (false, true) => crate::ast::TableAlign::Right, + _ => crate::ast::TableAlign::Default, + } +} + +/// Recognise a `[g]` checkbox at the start of `s`, where `g` is a single +/// glyph drawn from `listsyms` (or the rejected marker). Returns the bucketed +/// state and the byte length consumed (including the brackets). +fn checkbox_at(s: &str, listsyms: &ListSyms) -> Option<(CheckboxState, usize)> { + let rest = s.strip_prefix('[')?; + let mut chars = rest.char_indices(); + let (_, glyph) = chars.next()?; + let (close_off, ']') = chars.next()? else { + return None; + }; + let state = listsyms.state_of(glyph)?; + // `[` + glyph + `]`: 1 + glyph.len_utf8() + 1, i.e. the close bracket + // offset within `rest` plus one for the opening bracket and one for `]`. + Some((state, 1 + close_off + 1)) +} diff --git a/crates/nuwiki-core/src/syntax/vimwiki/mod.rs b/crates/nuwiki-core/src/syntax/vimwiki/mod.rs new file mode 100644 index 0000000..9f358b3 --- /dev/null +++ b/crates/nuwiki-core/src/syntax/vimwiki/mod.rs @@ -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) + } +} diff --git a/crates/nuwiki-core/src/syntax/vimwiki/parser.rs b/crates/nuwiki-core/src/syntax/vimwiki/parser.rs new file mode 100644 index 0000000..e0e0c0b --- /dev/null +++ b/crates/nuwiki-core/src/syntax/vimwiki/parser.rs @@ -0,0 +1,1434 @@ +//! Vimwiki parser. +//! +//! Hand-rolled recursive-descent over `Vec`. +//! Resilient: never aborts the document. Anything the dispatcher can't +//! claim becomes a `BlockNode::Error(ErrorNode)` so progress is +//! guaranteed. +//! +//! ## Inline marker precedence +//! +//! Within a text run, the lexer emits delimiter tokens +//! (`BoldDelim` / `ItalicDelim` / `StrikethroughDelim` / +//! `SuperscriptDelim` / `SubscriptDelim`) one at a time. The parser pairs +//! them by scanning forward for the *next* token of the same kind, then +//! recursing on the slice between them: +//! +//! - `*foo*` → Bold +//! - `_foo_` → Italic +//! - `~~foo~~` → Strikethrough +//! - `^foo^` → Superscript +//! - `,,foo,,` → Subscript +//! - `_*foo*_` → Italic(Bold(...)) — semantically bold-italic +//! - `*_foo_*` → Bold(Italic(...)) — semantically bold-italic +//! +//! Unmatched delimiters fall back to literal `Text` nodes so input like +//! `2 * 3 = 6` survives without a closing pair. +//! +//! ## Link target classification +//! +//! `[[ ]]` payloads are inspected to choose between `WikiLinkNode` +//! (the default) and `ExternalLinkNode` (when the target is a URL). The +//! `LinkTarget` enum follows the vimwiki conventions: leading `/` is root-relative, +//! `//` is filesystem-absolute, `wiki:` / `wn.:` are interwiki, +//! `diary:` / `file:` / `local:` are their own kinds, and a trailing `/` +//! marks a directory link. + +use crate::ast::{ + BlockNode, BlockquoteNode, BoldNode, CodeNode, CommentNode, DefinitionItemNode, + DefinitionListNode, DocumentNode, ErrorNode, ExternalLinkNode, HeadingNode, HorizontalRuleNode, + InlineNode, ItalicNode, KeywordNode, LinkKind, LinkTarget, ListItemNode, ListNode, ListSymbol, + MathBlockNode, MathInlineNode, PageMetadata, ParagraphNode, PreformattedNode, RawUrlNode, + SoftBreakNode, Span, StrikethroughNode, SubscriptNode, SuperscriptNode, TableCellNode, + TableNode, TableRowNode, TagNode, TagScope, TextNode, TransclusionNode, WikiLinkNode, +}; +use crate::syntax::{Parser, TokenStream}; + +use super::lexer::{VimwikiToken, VimwikiTokenKind as K}; + +#[derive(Debug, Default, Clone)] +pub struct VimwikiParser; + +impl VimwikiParser { + pub fn new() -> Self { + Self + } +} + +impl Parser for VimwikiParser { + type Token = VimwikiToken; + + fn parse(&self, tokens: TokenStream) -> DocumentNode { + let v = tokens.into_vec(); + let mut state = ParseState::new(&v); + state.parse_document() + } +} + +// ===== Cursor ===== + +struct ParseState<'a> { + toks: &'a [VimwikiToken], + pos: usize, + /// Count of `HeadingNode`s already pushed to `children`. + /// `headings_seen - 1` is the most recent heading's index. + headings_seen: usize, + /// Source line of the most recently-emitted heading. + /// `tag_line - last_heading_line ∈ {1, 2}` is a header-scope tag. + last_heading_line: Option, +} + +impl<'a> ParseState<'a> { + fn new(toks: &'a [VimwikiToken]) -> Self { + Self { + toks, + pos: 0, + headings_seen: 0, + last_heading_line: None, + } + } + + fn at_eof(&self) -> bool { + self.pos >= self.toks.len() + } + + fn peek(&self) -> Option<&'a VimwikiToken> { + self.toks.get(self.pos) + } + + fn peek_kind(&self) -> Option<&'a K> { + self.peek().map(|t| &t.kind) + } + + fn advance(&mut self) -> Option<&'a VimwikiToken> { + let t = self.toks.get(self.pos); + if t.is_some() { + self.pos += 1; + } + t + } + + fn skip_blanks(&mut self) { + while matches!(self.peek_kind(), Some(K::Newline) | Some(K::BlankLine)) { + self.pos += 1; + } + } + + fn eat_newline(&mut self) -> bool { + if matches!(self.peek_kind(), Some(K::Newline)) { + self.pos += 1; + true + } else { + false + } + } + + // ===== Document ===== + + fn parse_document(&mut self) -> DocumentNode { + let start_pos = self.toks.first().map(|t| t.span.start).unwrap_or_default(); + let end_pos = self.toks.last().map(|t| t.span.end).unwrap_or(start_pos); + + let mut metadata = PageMetadata::default(); + let mut children = Vec::new(); + + loop { + self.skip_blanks(); + if self.at_eof() { + break; + } + if self.consume_placeholder(&mut metadata) { + continue; + } + let before = self.pos; + let block = self.parse_block(); + // File-scope tags get accumulated into metadata so + // callers can read page-level tag membership without walking + // the AST. The TagNode itself stays in `children` so renderers + // see it too. + if let BlockNode::Tag(t) = &block { + if t.scope == TagScope::File { + for name in &t.tags { + if !metadata.tags.iter().any(|t| t == name) { + metadata.tags.push(name.clone()); + } + } + } + } + children.push(block); + // Resilience: guarantee forward progress. + if self.pos == before { + let span = self.toks.get(self.pos).map(|t| t.span).unwrap_or_default(); + children.push(BlockNode::Error(ErrorNode { + span, + raw: format!("{:?}", self.toks.get(self.pos).map(|t| &t.kind)), + message: "parser made no progress".to_owned(), + })); + self.pos += 1; + } + } + + DocumentNode { + span: Span::new(start_pos, end_pos), + children, + metadata, + } + } + + fn consume_placeholder(&mut self, metadata: &mut PageMetadata) -> bool { + let updated = match self.peek_kind() { + Some(K::PlaceholderTitle(v)) => { + metadata.title = v.clone(); + true + } + Some(K::PlaceholderNohtml) => { + metadata.nohtml = true; + true + } + Some(K::PlaceholderTemplate(v)) => { + metadata.template = v.clone(); + true + } + Some(K::PlaceholderDate(v)) => { + metadata.date = v.clone(); + true + } + _ => false, + }; + if updated { + self.advance(); + self.eat_newline(); + } + updated + } + + // ===== Block dispatch ===== + + fn parse_block(&mut self) -> BlockNode { + let kind = self.peek_kind().expect("parse_block called at EOF"); + match kind { + K::HeadingOpen { .. } => self.parse_heading(), + K::HorizontalRule => self.parse_horizontal_rule(), + K::ListMarker { .. } => self.parse_list(), + K::BlockquoteMarker | K::BlockquoteIndent => self.parse_blockquote(), + K::TableSep | K::TableHeaderRow(_) => self.parse_table(), + K::PreformattedOpen { .. } => self.parse_preformatted(), + K::MathBlockOpen { .. } => self.parse_math_block(), + K::CommentLine(_) => self.parse_single_comment(), + K::CommentMultiOpen => self.parse_multi_comment(), + K::Tag(_) => self.parse_tag(), + _ => { + if self.is_definition_start() { + self.parse_definition_list() + } else { + self.parse_paragraph() + } + } + } + } + + // ===== Tag ===== + + fn parse_tag(&mut self) -> BlockNode { + let t = self.advance().unwrap(); + let names = match &t.kind { + K::Tag(v) => v.clone(), + _ => unreachable!(), + }; + let span = t.span; + let scope = self.scope_for_tag(span.start.line); + self.eat_newline(); + BlockNode::Tag(TagNode { + span, + tags: names, + scope, + }) + } + + /// Decide the placement-determined scope of a tag at the given source + /// line, matching vimwiki's rules: + /// - lines 0 or 1 → `File` + /// - within two lines after the most recent heading → `Heading(idx)` + /// - otherwise → `Standalone` + fn scope_for_tag(&self, tag_line: u32) -> TagScope { + if tag_line <= 1 { + return TagScope::File; + } + if let Some(h_line) = self.last_heading_line { + let delta = tag_line.saturating_sub(h_line); + if (1..=2).contains(&delta) && self.headings_seen > 0 { + return TagScope::Heading(self.headings_seen - 1); + } + } + TagScope::Standalone + } + + // ===== Heading ===== + + fn parse_heading(&mut self) -> BlockNode { + let open = self.advance().unwrap(); + let span_start = open.span.start; + let (level, centered) = match &open.kind { + K::HeadingOpen { level, centered } => (*level, *centered), + _ => unreachable!(), + }; + let inline_start = self.pos; + let mut span_end = open.span.end; + while let Some(t) = self.peek() { + match &t.kind { + K::HeadingClose => { + span_end = t.span.end; + let inline = parse_inline_seq(&self.toks[inline_start..self.pos]); + self.advance(); + self.eat_newline(); + // Record for tag-scope resolution. + self.headings_seen += 1; + self.last_heading_line = Some(span_end.line); + return BlockNode::Heading(HeadingNode { + span: Span::new(span_start, span_end), + level, + centered, + children: inline, + }); + } + K::Newline | K::BlankLine => break, + _ => { + self.advance(); + } + } + } + // No closing `=` seen: emit a paragraph from what we collected. + let inline = parse_inline_seq(&self.toks[inline_start..self.pos]); + self.eat_newline(); + BlockNode::Paragraph(ParagraphNode { + span: Span::new(span_start, span_end), + children: inline, + }) + } + + // ===== Horizontal rule ===== + + fn parse_horizontal_rule(&mut self) -> BlockNode { + let t = self.advance().unwrap(); + let span = t.span; + self.eat_newline(); + BlockNode::HorizontalRule(HorizontalRuleNode { span }) + } + + // ===== Comments ===== + + fn parse_single_comment(&mut self) -> BlockNode { + let t = self.advance().unwrap(); + let content = match &t.kind { + K::CommentLine(s) => s.clone(), + _ => unreachable!(), + }; + let span = t.span; + self.eat_newline(); + BlockNode::Comment(CommentNode { span, content }) + } + + fn parse_multi_comment(&mut self) -> BlockNode { + let open = self.advance().unwrap(); + let span_start = open.span.start; + let mut span_end = open.span.end; + let mut content = String::new(); + let mut first = true; + while let Some(t) = self.peek() { + match &t.kind { + K::CommentMultiClose => { + span_end = t.span.end; + self.advance(); + self.eat_newline(); + return BlockNode::Comment(CommentNode { + span: Span::new(span_start, span_end), + content, + }); + } + K::CommentMultiLine(s) => { + if !first { + content.push('\n'); + } + content.push_str(s); + first = false; + span_end = t.span.end; + self.advance(); + } + K::Newline => { + self.advance(); + } + _ => { + self.advance(); + } + } + } + // Unterminated: keep what we have. + BlockNode::Comment(CommentNode { + span: Span::new(span_start, span_end), + content, + }) + } + + // ===== Preformatted ===== + + fn parse_preformatted(&mut self) -> BlockNode { + let open = self.advance().unwrap(); + let span_start = open.span.start; + let mut span_end = open.span.end; + let language = match &open.kind { + K::PreformattedOpen { language, .. } => language.clone(), + _ => unreachable!(), + }; + let mut content = String::new(); + let mut first = true; + while let Some(t) = self.peek() { + match &t.kind { + K::PreformattedClose => { + span_end = t.span.end; + self.advance(); + self.eat_newline(); + return BlockNode::Preformatted(PreformattedNode { + span: Span::new(span_start, span_end), + content, + language, + }); + } + K::PreformattedLine(s) => { + if !first { + content.push('\n'); + } + content.push_str(s); + first = false; + span_end = t.span.end; + self.advance(); + } + K::Newline => { + self.advance(); + } + _ => { + self.advance(); + } + } + } + BlockNode::Preformatted(PreformattedNode { + span: Span::new(span_start, span_end), + content, + language, + }) + } + + // ===== Math block ===== + + fn parse_math_block(&mut self) -> BlockNode { + let open = self.advance().unwrap(); + let span_start = open.span.start; + let mut span_end = open.span.end; + let environment = match &open.kind { + K::MathBlockOpen { environment } => environment.clone(), + _ => unreachable!(), + }; + let mut content = String::new(); + let mut first = true; + while let Some(t) = self.peek() { + match &t.kind { + K::MathBlockClose => { + span_end = t.span.end; + self.advance(); + self.eat_newline(); + return BlockNode::MathBlock(MathBlockNode { + span: Span::new(span_start, span_end), + content, + environment, + }); + } + K::MathBlockLine(s) => { + if !first { + content.push('\n'); + } + content.push_str(s); + first = false; + span_end = t.span.end; + self.advance(); + } + K::Newline => { + self.advance(); + } + _ => { + self.advance(); + } + } + } + BlockNode::MathBlock(MathBlockNode { + span: Span::new(span_start, span_end), + content, + environment, + }) + } + + // ===== List ===== + + fn parse_list(&mut self) -> BlockNode { + let first = self.peek().expect("parse_list at EOF"); + let span_start = first.span.start; + let (base_symbol, base_indent) = match &first.kind { + K::ListMarker { symbol, indent } => (*symbol, *indent), + _ => unreachable!(), + }; + let mut items = Vec::new(); + let mut span_end = span_start; + while let Some(t) = self.peek() { + match &t.kind { + K::ListMarker { indent, .. } if *indent == base_indent => { + let item = self.parse_list_item(); + span_end = item.span.end; + items.push(item); + } + _ => break, + } + } + BlockNode::List(ListNode { + span: Span::new(span_start, span_end), + ordered: matches!( + base_symbol, + ListSymbol::Numeric + | ListSymbol::NumericParen + | ListSymbol::AlphaParen + | ListSymbol::AlphaUpperParen + | ListSymbol::RomanParen + | ListSymbol::RomanUpperParen + ), + symbol: base_symbol, + items, + }) + } + + fn parse_list_item(&mut self) -> ListItemNode { + let marker = self.advance().expect("list item without marker"); + let span_start = marker.span.start; + let mut span_end = marker.span.end; + let (symbol, level) = match &marker.kind { + K::ListMarker { symbol, indent } => (*symbol, *indent as usize), + _ => unreachable!(), + }; + + // Optional Checkbox + let checkbox = if let Some(K::Checkbox(state)) = self.peek_kind() { + let state = *state; + let t = self.advance().unwrap(); + span_end = t.span.end; + Some(state) + } else { + None + }; + + // Inline content until end of line. + let inline_start = self.pos; + while let Some(t) = self.peek() { + match &t.kind { + K::Newline | K::BlankLine => break, + _ => { + span_end = t.span.end; + self.advance(); + } + } + } + let inline_end = self.pos; + let mut children = parse_inline_seq(&self.toks[inline_start..inline_end]); + self.eat_newline(); + + // Continuation lines (vimwiki parity): lines indented strictly past + // the marker's column attach to this item rather than becoming a + // sibling paragraph. The lexer doesn't model list state, so we + // detect this here by inspecting whatever the next line opens + // with: either a `Text(s)` whose leading whitespace exceeds the + // marker's column, or a `BlockquoteIndent` token (the lexer emits + // it for 4+ leading spaces, swallowing them from the following + // Text). We accept either as a continuation when the implied + // indent is `> level`. + let continuation_threshold = level + 1; + loop { + let Some(first) = self.peek() else { break }; + let mut consume_first = false; + let is_continuation = match &first.kind { + K::Text(s) => leading_ws_count(s) >= continuation_threshold, + K::BlockquoteIndent => { + // `BlockquoteIndent`'s span covers the leading-whitespace + // run on the current line. Width ≥ threshold means this + // is a continuation, not a blockquote. + let width = (first.span.end.column - first.span.start.column) as usize; + if width >= continuation_threshold { + consume_first = true; + true + } else { + false + } + } + _ => false, + }; + if !is_continuation { + break; + } + // Eat the synthetic BlockquoteIndent if present — the lexer + // already stripped the whitespace from the following Text so + // we don't want to also emit a leading space ourselves. + if consume_first { + self.advance(); + } + let cont_start = self.pos; + while let Some(t) = self.peek() { + match &t.kind { + K::Newline | K::BlankLine => break, + _ => { + span_end = t.span.end; + self.advance(); + } + } + } + let cont_end = self.pos; + // Drop the leading whitespace on the first text token (only + // present when we came via the Text branch — the + // BlockquoteIndent path already stripped it). + let mut buf: Vec = self.toks[cont_start..cont_end].to_vec(); + if !consume_first { + if let Some(t0) = buf.first_mut() { + if let K::Text(text) = &t0.kind { + let trimmed = text.trim_start().to_owned(); + t0.kind = K::Text(trimmed); + } + } + } + // Insert a soft break between the previous content and the + // continuation so the rendered text flows naturally (and honours + // `*_ignore_newline`). + if !children.is_empty() { + children.push(InlineNode::SoftBreak(SoftBreakNode { span: first.span })); + } + let cont = parse_inline_seq(&buf); + children.extend(cont); + if !self.eat_newline() { + break; + } + } + + // Sublist: subsequent ListMarker tokens with deeper indent. + let sublist = if let Some(K::ListMarker { indent, .. }) = self.peek_kind() { + if (*indent as usize) > level { + let block = self.parse_list(); + if let BlockNode::List(node) = block { + span_end = node.span.end; + Some(node) + } else { + None + } + } else { + None + } + } else { + None + }; + + ListItemNode { + span: Span::new(span_start, span_end), + symbol, + level, + checkbox, + children, + sublist, + } + } + + // ===== Blockquote ===== + + fn parse_blockquote(&mut self) -> BlockNode { + let first = self.peek().unwrap(); + let span_start = first.span.start; + let mut span_end = span_start; + let mut lines: Vec> = Vec::new(); + while let Some(t) = self.peek() { + match &t.kind { + K::BlockquoteMarker | K::BlockquoteIndent => { + self.advance(); + let inline_start = self.pos; + while let Some(t2) = self.peek() { + match &t2.kind { + K::Newline | K::BlankLine => break, + _ => { + span_end = t2.span.end; + self.advance(); + } + } + } + let inline_end = self.pos; + let inline = parse_inline_seq(&self.toks[inline_start..inline_end]); + if !inline.is_empty() { + lines.push(inline); + } + self.eat_newline(); + } + _ => break, + } + } + let mut children: Vec = Vec::with_capacity(lines.len()); + for line in lines { + let line_start = line + .first() + .and_then(span_start_of_inline) + .unwrap_or(span_start); + let line_end = line.last().and_then(span_end_of_inline).unwrap_or(span_end); + children.push(BlockNode::Paragraph(ParagraphNode { + span: Span::new(line_start, line_end), + children: line, + })); + } + BlockNode::Blockquote(BlockquoteNode { + span: Span::new(span_start, span_end), + children, + }) + } + + // ===== Table ===== + + fn parse_table(&mut self) -> BlockNode { + let first = self.peek().unwrap(); + let span_start = first.span.start; + let mut span_end = span_start; + let mut rows: Vec = Vec::new(); + let mut next_is_header = false; + let mut has_header = false; + let mut alignments: Vec = Vec::new(); + + while let Some(t) = self.peek() { + match &t.kind { + K::TableHeaderRow(aligns) => { + has_header = true; + if alignments.is_empty() { + alignments = aligns.clone(); + } + if let Some(last) = rows.last_mut() { + last.is_header = true; + } + next_is_header = true; + span_end = t.span.end; + self.advance(); + self.eat_newline(); + } + K::TableSep => { + let row = self.parse_table_row(); + span_end = row.span.end; + rows.push(row); + if next_is_header { + next_is_header = false; + } + } + _ => break, + } + } + BlockNode::Table(TableNode { + span: Span::new(span_start, span_end), + rows, + has_header, + alignments, + }) + } + + fn parse_table_row(&mut self) -> TableRowNode { + // Consume leading TableSep + let first_sep = self.advance().expect("table row without leading |"); + let span_start = first_sep.span.start; + let mut span_end = first_sep.span.end; + let mut cells: Vec = Vec::new(); + + while let Some(t) = self.peek() { + match &t.kind { + K::Newline | K::BlankLine => break, + K::TableSep => { + span_end = t.span.end; + self.advance(); + } + K::TableColSpan => { + let span = t.span; + span_end = span.end; + self.advance(); + cells.push(TableCellNode { + span, + children: Vec::new(), + col_span: true, + row_span: false, + }); + } + K::TableRowSpan => { + let span = t.span; + span_end = span.end; + self.advance(); + cells.push(TableCellNode { + span, + children: Vec::new(), + col_span: false, + row_span: true, + }); + } + _ => { + let cell_start_idx = self.pos; + let cell_start_pos = t.span.start; + let mut cell_end_pos = t.span.end; + while let Some(tt) = self.peek() { + match &tt.kind { + K::TableSep | K::Newline | K::BlankLine => break, + _ => { + cell_end_pos = tt.span.end; + self.advance(); + } + } + } + let inline = parse_inline_seq(&self.toks[cell_start_idx..self.pos]); + span_end = cell_end_pos; + cells.push(TableCellNode { + span: Span::new(cell_start_pos, cell_end_pos), + children: inline, + col_span: false, + row_span: false, + }); + } + } + } + self.eat_newline(); + TableRowNode { + span: Span::new(span_start, span_end), + cells, + is_header: false, + } + } + + // ===== Definition list ===== + + fn is_definition_start(&self) -> bool { + // Walk forward on the current line. If we find a DefinitionTermMarker + // before a Newline/BlankLine, treat as a definition. + let mut i = self.pos; + while let Some(t) = self.toks.get(i) { + match &t.kind { + K::Newline | K::BlankLine => return false, + K::DefinitionTermMarker => return true, + _ => i += 1, + } + } + false + } + + fn parse_definition_list(&mut self) -> BlockNode { + let first = self.peek().unwrap(); + let span_start = first.span.start; + let mut span_end = span_start; + let mut items: Vec = Vec::new(); + while self.is_definition_start() { + let item = self.parse_definition_item(); + span_end = item.span.end; + items.push(item); + } + BlockNode::DefinitionList(DefinitionListNode { + span: Span::new(span_start, span_end), + items, + }) + } + + fn parse_definition_item(&mut self) -> DefinitionItemNode { + let item_start_idx = self.pos; + let item_span_start = self.toks[item_start_idx].span.start; + + // Term: tokens until DefinitionTermMarker + let term_start = self.pos; + while let Some(t) = self.peek() { + match &t.kind { + K::DefinitionTermMarker | K::Newline | K::BlankLine => break, + _ => { + self.advance(); + } + } + } + let term_end = self.pos; + let term_inline = if term_end > term_start { + let v = parse_inline_seq(&self.toks[term_start..term_end]); + if v.is_empty() { + None + } else { + Some(v) + } + } else { + None + }; + + let mut span_end = self + .toks + .get(term_end.saturating_sub(1)) + .map(|t| t.span.end) + .unwrap_or(item_span_start); + + // DefinitionTermMarker + if matches!(self.peek_kind(), Some(K::DefinitionTermMarker)) { + span_end = self.peek().unwrap().span.end; + self.advance(); + } + + // Definition tokens until end of line + let def_start = self.pos; + while let Some(t) = self.peek() { + match &t.kind { + K::Newline | K::BlankLine => break, + _ => { + span_end = t.span.end; + self.advance(); + } + } + } + let def_end = self.pos; + let mut definitions = Vec::new(); + if def_end > def_start { + let v = parse_inline_seq(&self.toks[def_start..def_end]); + if !v.is_empty() { + definitions.push(v); + } + } + self.eat_newline(); + + DefinitionItemNode { + span: Span::new(item_span_start, span_end), + term: term_inline, + definitions, + } + } + + // ===== Paragraph ===== + + fn parse_paragraph(&mut self) -> BlockNode { + let span_start = self.peek().unwrap().span.start; + let inline_start = self.pos; + // `content_end` excludes a trailing newline that just terminates + // the paragraph (instead of joining two content lines), so it + // doesn't leak into `parse_inline_seq` as a stray soft-break " ". + let mut content_end = self.pos; + let mut span_end = span_start; + + loop { + match self.peek_kind() { + None => break, + Some(K::Newline) => { + span_end = self.peek().unwrap().span.end; + self.advance(); + match self.peek_kind() { + None => break, + Some(k) if starts_new_block(k) => break, + // Soft-break: the newline joins two content lines. + // Pull it into the inline range as a space. + _ => content_end = self.pos, + } + } + Some(k) if starts_new_block(k) => break, + Some(_) => { + span_end = self.peek().unwrap().span.end; + self.advance(); + content_end = self.pos; + } + } + } + + let children = parse_inline_seq(&self.toks[inline_start..content_end]); + BlockNode::Paragraph(ParagraphNode { + span: Span::new(span_start, span_end), + children, + }) + } +} + +fn starts_new_block(kind: &K) -> bool { + matches!( + kind, + K::BlankLine + | K::HeadingOpen { .. } + | K::HorizontalRule + | K::ListMarker { .. } + | K::BlockquoteMarker + | K::BlockquoteIndent + | K::TableSep + | K::TableHeaderRow(_) + | K::PreformattedOpen { .. } + | K::MathBlockOpen { .. } + | K::CommentLine(_) + | K::CommentMultiOpen + | K::PlaceholderTitle(_) + | K::PlaceholderNohtml + | K::PlaceholderTemplate(_) + | K::PlaceholderDate(_) + | K::Tag(_) + ) +} + +// ===== Inline pairing ===== + +fn parse_inline_seq(toks: &[VimwikiToken]) -> Vec { + let mut out = Vec::new(); + let mut i = 0; + while i < toks.len() { + let token = &toks[i]; + match &token.kind { + K::Text(s) => { + out.push(InlineNode::Text(TextNode { + span: token.span, + content: s.clone(), + })); + i += 1; + } + K::Code(s) => { + out.push(InlineNode::Code(CodeNode { + span: token.span, + content: s.clone(), + })); + i += 1; + } + K::MathInline(s) => { + out.push(InlineNode::MathInline(MathInlineNode { + span: token.span, + content: s.clone(), + })); + i += 1; + } + K::Keyword(k) => { + out.push(InlineNode::Keyword(KeywordNode { + span: token.span, + keyword: *k, + })); + i += 1; + } + K::RawUrl(u) => { + out.push(InlineNode::RawUrl(RawUrlNode { + span: token.span, + url: u.clone(), + })); + i += 1; + } + K::Newline => { + // Soft break inside a multi-line block. A dedicated node lets + // the renderer honour `*_ignore_newline` (space vs `
    `); + // other consumers treat it as whitespace. + out.push(InlineNode::SoftBreak(SoftBreakNode { span: token.span })); + i += 1; + } + K::BoldDelim => { + i = pair_or_text(toks, i, "*", &mut out, K::BoldDelim, |span, kids| { + InlineNode::Bold(BoldNode { + span, + children: kids, + }) + }) + } + K::ItalicDelim => { + i = pair_or_text(toks, i, "_", &mut out, K::ItalicDelim, |span, kids| { + InlineNode::Italic(ItalicNode { + span, + children: kids, + }) + }) + } + K::StrikethroughDelim => { + i = pair_or_text( + toks, + i, + "~~", + &mut out, + K::StrikethroughDelim, + |span, kids| { + InlineNode::Strikethrough(StrikethroughNode { + span, + children: kids, + }) + }, + ) + } + K::SuperscriptDelim => { + i = pair_or_text(toks, i, "^", &mut out, K::SuperscriptDelim, |span, kids| { + InlineNode::Superscript(SuperscriptNode { + span, + children: kids, + }) + }) + } + K::SubscriptDelim => { + i = pair_or_text(toks, i, ",,", &mut out, K::SubscriptDelim, |span, kids| { + InlineNode::Subscript(SubscriptNode { + span, + children: kids, + }) + }) + } + K::WikiLinkOpen => { + let (node, next) = parse_wikilink(toks, i); + out.push(node); + i = next; + } + K::TransclusionOpen => { + let (node, next) = parse_transclusion(toks, i); + out.push(node); + i = next; + } + // Tokens that shouldn't appear in inline context (e.g. dangling + // close delimiters, separators outside their owners): emit as + // literal text so nothing is silently dropped. + K::WikiLinkClose => push_literal(token, "]]", &mut out, &mut i), + K::WikiLinkSep => push_literal(token, "|", &mut out, &mut i), + K::TransclusionClose => push_literal(token, "}}", &mut out, &mut i), + K::TransclusionSep => push_literal(token, "|", &mut out, &mut i), + K::HeadingClose => push_literal(token, "=", &mut out, &mut i), + // Other tokens (block markers, etc.) should not appear here; skip. + _ => { + i += 1; + } + } + } + out +} + +fn push_literal(token: &VimwikiToken, lit: &str, out: &mut Vec, i: &mut usize) { + out.push(InlineNode::Text(TextNode { + span: token.span, + content: lit.into(), + })); + *i += 1; +} + +fn pair_or_text( + toks: &[VimwikiToken], + open_idx: usize, + literal: &str, + out: &mut Vec, + target: K, + build: F, +) -> usize +where + F: FnOnce(Span, Vec) -> InlineNode, +{ + if let Some(close_idx) = find_close(toks, open_idx, &target) { + let inner = parse_inline_seq(&toks[open_idx + 1..close_idx]); + let span = Span::new(toks[open_idx].span.start, toks[close_idx].span.end); + out.push(build(span, inner)); + close_idx + 1 + } else { + out.push(InlineNode::Text(TextNode { + span: toks[open_idx].span, + content: literal.into(), + })); + open_idx + 1 + } +} + +fn find_close(toks: &[VimwikiToken], start: usize, target: &K) -> Option { + for (offset, t) in toks.iter().enumerate().skip(start + 1) { + if &t.kind == target { + return Some(offset); + } + } + None +} + +// ===== Wikilink + transclusion ===== + +fn parse_wikilink(toks: &[VimwikiToken], open_idx: usize) -> (InlineNode, usize) { + let span_start = toks[open_idx].span.start; + let mut i = open_idx + 1; + let target_start = i; + let mut target_end = i; + let mut sep_idx: Option = None; + let mut close_idx: Option = None; + while i < toks.len() { + match &toks[i].kind { + K::WikiLinkClose => { + close_idx = Some(i); + break; + } + K::WikiLinkSep if sep_idx.is_none() => { + sep_idx = Some(i); + target_end = i; + } + _ => {} + } + i += 1; + } + let close_idx = match close_idx { + Some(c) => c, + None => { + // Unterminated link: emit literal `[[`. + return ( + InlineNode::Text(TextNode { + span: toks[open_idx].span, + content: "[[".into(), + }), + open_idx + 1, + ); + } + }; + + if sep_idx.is_none() { + target_end = close_idx; + } + + let target_text = collect_text(&toks[target_start..target_end]); + let span = Span::new(span_start, toks[close_idx].span.end); + + let description: Option> = sep_idx.map(|sep| { + let inner = parse_inline_seq(&toks[sep + 1..close_idx]); + if inner.is_empty() { + vec![] + } else { + inner + } + }); + + if is_url(&target_text) { + ( + InlineNode::ExternalLink(ExternalLinkNode { + span, + url: target_text, + description, + }), + close_idx + 1, + ) + } else { + let target = parse_link_target(&target_text); + ( + InlineNode::WikiLink(WikiLinkNode { + span, + target, + description, + }), + close_idx + 1, + ) + } +} + +fn parse_transclusion(toks: &[VimwikiToken], open_idx: usize) -> (InlineNode, usize) { + let span_start = toks[open_idx].span.start; + let mut i = open_idx + 1; + let url_start = i; + let mut url_end = i; + let mut seps: Vec = Vec::new(); + let mut close_idx: Option = None; + while i < toks.len() { + match &toks[i].kind { + K::TransclusionClose => { + close_idx = Some(i); + break; + } + K::TransclusionSep => { + if seps.is_empty() { + url_end = i; + } + seps.push(i); + } + _ => {} + } + i += 1; + } + let close_idx = match close_idx { + Some(c) => c, + None => { + return ( + InlineNode::Text(TextNode { + span: toks[open_idx].span, + content: "{{".into(), + }), + open_idx + 1, + ); + } + }; + if seps.is_empty() { + url_end = close_idx; + } + let url = collect_text(&toks[url_start..url_end]); + let span = Span::new(span_start, toks[close_idx].span.end); + + // After URL: optional alt, then key=val attrs. + let mut alt = None; + let mut attrs = std::collections::HashMap::new(); + if !seps.is_empty() { + let alt_start = seps[0] + 1; + let alt_end = if seps.len() >= 2 { seps[1] } else { close_idx }; + let alt_text = collect_text(&toks[alt_start..alt_end]); + if !alt_text.is_empty() { + alt = Some(alt_text); + } + for window in seps.windows(2) { + let s = window[0]; + let e = window[1]; + let segment = collect_text(&toks[s + 1..e]); + insert_attr(&segment, &mut attrs); + } + // Last attribute segment between final sep and close. + if let Some(&last) = seps.last() { + if last + 1 < close_idx && seps.len() >= 2 { + let segment = collect_text(&toks[last + 1..close_idx]); + insert_attr(&segment, &mut attrs); + } + } + } + + ( + InlineNode::Transclusion(TransclusionNode { + span, + url, + alt, + attrs, + }), + close_idx + 1, + ) +} + +/// Count leading space / tab characters at the start of `s`. Used by +/// `parse_list_item` to detect continuation lines. +fn leading_ws_count(s: &str) -> usize { + s.chars().take_while(|c| *c == ' ' || *c == '\t').count() +} + +/// Split a `key=value` attribute segment, strip surrounding quotes from +/// the value, and insert into `attrs`. Empty keys are skipped. +fn insert_attr(segment: &str, attrs: &mut std::collections::HashMap) { + let Some(eq) = segment.find('=') else { return }; + let k = segment[..eq].trim().to_owned(); + if k.is_empty() { + return; + } + let raw = segment[eq + 1..].trim(); + let v = strip_quotes(raw).to_owned(); + attrs.insert(k, v); +} + +fn strip_quotes(s: &str) -> &str { + let b = s.as_bytes(); + if b.len() >= 2 + && ((b[0] == b'"' && b[b.len() - 1] == b'"') || (b[0] == b'\'' && b[b.len() - 1] == b'\'')) + { + &s[1..s.len() - 1] + } else { + s + } +} + +fn collect_text(toks: &[VimwikiToken]) -> String { + let mut out = String::new(); + for t in toks { + if let K::Text(s) = &t.kind { + out.push_str(s); + } + } + out +} + +fn is_url(s: &str) -> bool { + s.starts_with("http://") + || s.starts_with("https://") + || s.starts_with("ftp://") + || s.starts_with("mailto:") + || s.starts_with("file://") +} + +// ===== LinkTarget classification ===== + +fn parse_link_target(text: &str) -> LinkTarget { + let mut target = LinkTarget { + kind: LinkKind::Wiki, + path: None, + wiki_index: None, + wiki_name: None, + anchor: None, + is_absolute: false, + is_directory: false, + }; + + // Split off anchor first. + let (path_part, anchor) = match text.split_once('#') { + Some((p, a)) => (p, Some(a.to_owned())), + None => (text, None), + }; + target.anchor = anchor; + + if path_part.is_empty() { + target.kind = LinkKind::AnchorOnly; + return target; + } + + // `//path` — filesystem-absolute. + if let Some(rest) = path_part.strip_prefix("//") { + target.kind = LinkKind::Local; + target.is_absolute = true; + let (path, is_dir) = strip_directory(rest); + target.is_directory = is_dir; + target.path = Some(path); + return target; + } + + // `/Page` — root-relative wiki link. + if let Some(rest) = path_part.strip_prefix('/') { + target.kind = LinkKind::Wiki; + target.is_absolute = true; + let (path, is_dir) = strip_directory(rest); + target.is_directory = is_dir; + target.path = Some(path); + return target; + } + + // Scheme prefixes. + if let Some(rest) = path_part.strip_prefix("file:") { + target.kind = LinkKind::File; + target.path = Some(rest.to_owned()); + return target; + } + if let Some(rest) = path_part.strip_prefix("local:") { + target.kind = LinkKind::Local; + target.path = Some(rest.to_owned()); + return target; + } + if let Some(rest) = path_part.strip_prefix("diary:") { + target.kind = LinkKind::Diary; + target.path = Some(rest.to_owned()); + return target; + } + if let Some(rest) = path_part.strip_prefix("wn.") { + if let Some((name, page)) = rest.split_once(':') { + target.kind = LinkKind::Interwiki; + target.wiki_name = Some(name.to_owned()); + target.path = Some(page.to_owned()); + return target; + } + } + if let Some((scheme, rest)) = path_part.split_once(':') { + if let Some(num) = scheme.strip_prefix("wiki") { + if let Ok(idx) = num.parse::() { + target.kind = LinkKind::Interwiki; + target.wiki_index = Some(idx); + target.path = Some(rest.to_owned()); + return target; + } + } + } + + // Default: plain wiki link, possibly directory. + let (path, is_dir) = strip_directory(path_part); + target.kind = LinkKind::Wiki; + target.is_directory = is_dir; + target.path = Some(path); + target +} + +fn strip_directory(s: &str) -> (String, bool) { + if let Some(stripped) = s.strip_suffix('/') { + (stripped.to_owned(), true) + } else { + (s.to_owned(), false) + } +} + +// ===== Span helpers ===== + +fn span_start_of_inline(node: &InlineNode) -> Option { + Some(node.span().start) +} + +fn span_end_of_inline(node: &InlineNode) -> Option { + Some(node.span().end) +} diff --git a/crates/nuwiki-core/tests/diary.rs b/crates/nuwiki-core/tests/diary.rs new file mode 100644 index 0000000..ded92ef --- /dev/null +++ b/crates/nuwiki-core/tests/diary.rs @@ -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)); +} diff --git a/crates/nuwiki-core/tests/html_renderer.rs b/crates/nuwiki-core/tests/html_renderer.rs new file mode 100644 index 0000000..1ead749 --- /dev/null +++ b/crates/nuwiki-core/tests/html_renderer.rs @@ -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>, has_header: bool) -> DocumentNode { + let span = Span::default(); + let rows: Vec = 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!("Title"))); + } +} + +#[test] +fn centered_heading_gets_centered_class() { + let out = render(" = T =\n"); + assert!(out.contains("

    T

    ")); +} + +#[test] +fn paragraph_wraps_inline_content() { + let out = render("Hello world\n"); + assert_eq!(out.trim(), "

    Hello world

    "); +} + +#[test] +fn horizontal_rule() { + let out = render("----\n"); + assert!(out.contains("
    ")); +} + +#[test] +fn blockquote_lines_become_paragraphs() { + let out = render("> first\n> second\n"); + assert!(out.contains("
    ")); + assert!(out.contains("

    first

    ")); + assert!(out.contains("

    second

    ")); +} + +#[test] +fn preformatted_block_emits_pre_code_with_language_class() { + let out = render("{{{rust\nfn main() {}\n}}}\n"); + assert!(out.contains("
    "));
    +    assert!(out.contains("fn main() {}"));
    +    assert!(out.contains("
    ")); +} + +#[test] +fn math_block_emits_div() { + let out = render("{{$\nx=1\n}}$\n"); + assert!(out.contains("
    ")); + assert!(out.contains("x=1")); +} + +#[test] +fn unordered_list_with_checkbox() { + let out = render("- [X] done\n- not done\n"); + assert!(out.contains("