From 8ab60154050453b271d9a032593f2a3ca95cdad3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriel=20Fr=C3=B3es=20Franco?= Date: Sat, 30 May 2026 18:35:40 +0000 Subject: [PATCH] Major clean up and improvements to vimwiki compatibility and documentation. Reviewed-on: https://code.gfran.co/gffranco/nuwiki/pulls/1 --- .gitea/workflows/ci.yaml | 4 +- README.md | 4 +- SPEC.md | 1425 ----------------- autoload/nuwiki/commands.vim | 28 +- autoload/nuwiki/lsp.vim | 2 +- crates/nuwiki-core/src/ast/block.rs | 4 +- crates/nuwiki-core/src/ast/inline.rs | 2 +- crates/nuwiki-core/src/ast/link.rs | 2 +- crates/nuwiki-core/src/ast/mod.rs | 9 +- crates/nuwiki-core/src/ast/span.rs | 2 +- crates/nuwiki-core/src/ast/visit.rs | 4 +- crates/nuwiki-core/src/date.rs | 6 +- crates/nuwiki-core/src/lib.rs | 2 +- crates/nuwiki-core/src/render/html.rs | 10 +- crates/nuwiki-core/src/render/mod.rs | 2 +- crates/nuwiki-core/src/syntax/mod.rs | 10 +- crates/nuwiki-core/src/syntax/registry.rs | 2 +- .../nuwiki-core/src/syntax/vimwiki/lexer.rs | 12 +- crates/nuwiki-core/src/syntax/vimwiki/mod.rs | 2 +- .../nuwiki-core/src/syntax/vimwiki/parser.rs | 12 +- crates/nuwiki-core/tests/syntax.rs | 2 +- crates/nuwiki-core/tests/tags.rs | 2 +- crates/nuwiki-lsp/src/commands.rs | 122 +- crates/nuwiki-lsp/src/config.rs | 36 +- crates/nuwiki-lsp/src/diagnostics.rs | 7 +- crates/nuwiki-lsp/src/diary.rs | 2 +- crates/nuwiki-lsp/src/edits.rs | 2 +- crates/nuwiki-lsp/src/export.rs | 2 +- crates/nuwiki-lsp/src/folding.rs | 2 +- crates/nuwiki-lsp/src/index.rs | 20 +- crates/nuwiki-lsp/src/lib.rs | 53 +- crates/nuwiki-lsp/src/nav.rs | 2 +- crates/nuwiki-lsp/src/rename.rs | 4 +- crates/nuwiki-lsp/src/semantic_tokens.rs | 5 +- crates/nuwiki-lsp/src/wiki.rs | 8 +- .../nuwiki-lsp/tests/commands_checkboxes.rs | 18 + crates/nuwiki-lsp/tests/commands_colorize.rs | 4 +- crates/nuwiki-lsp/tests/commands_coverage.rs | 532 ++++++ crates/nuwiki-lsp/tests/commands_files.rs | 6 +- crates/nuwiki-lsp/tests/commands_lists.rs | 42 +- crates/nuwiki-lsp/tests/commands_tables.rs | 2 +- crates/nuwiki-lsp/tests/diary.rs | 2 +- crates/nuwiki-lsp/tests/folding.rs | 2 +- crates/nuwiki-lsp/tests/helpers.rs | 4 +- crates/nuwiki-lsp/tests/html_export.rs | 4 +- crates/nuwiki-lsp/tests/link_health.rs | 4 +- crates/nuwiki-lsp/tests/multi_wiki.rs | 10 +- crates/nuwiki-lsp/tests/navigation.rs | 4 +- development/ONBOARDING.md | 249 +++ development/SPEC.md | 339 ++++ development/_common.sh | 276 ++++ development/nuwiki-architecture.html | 324 ++++ start-nvim.sh => development/start-nvim.sh | 85 +- start-vim.sh => development/start-vim.sh | 85 +- {scripts => development}/syntax-diag.vim | 4 +- .../tests}/test-keymaps-vim.sh | 11 +- .../tests}/test-keymaps-vim.vim | 111 +- .../tests}/test-keymaps.lua | 181 ++- .../tests}/test-keymaps.sh | 10 +- doc/nuwiki.txt | 135 +- ftplugin/vimwiki.vim | 47 +- lua/nuwiki/commands.lua | 25 +- lua/nuwiki/config.lua | 15 +- lua/nuwiki/ftplugin.lua | 4 +- lua/nuwiki/health.lua | 4 +- lua/nuwiki/init.lua | 2 +- lua/nuwiki/install.lua | 2 +- lua/nuwiki/keymaps.lua | 14 +- plugin/nuwiki.vim | 2 +- syntax/vimwiki.vim | 4 +- test-personal-wiki.sh | 34 - 71 files changed, 2496 insertions(+), 1914 deletions(-) delete mode 100644 SPEC.md create mode 100644 crates/nuwiki-lsp/tests/commands_coverage.rs create mode 100644 development/ONBOARDING.md create mode 100644 development/SPEC.md create mode 100644 development/_common.sh create mode 100644 development/nuwiki-architecture.html rename start-nvim.sh => development/start-nvim.sh (54%) rename start-vim.sh => development/start-vim.sh (61%) rename {scripts => development}/syntax-diag.vim (94%) rename {scripts => development/tests}/test-keymaps-vim.sh (78%) rename {scripts => development/tests}/test-keymaps-vim.vim (70%) rename {scripts => development/tests}/test-keymaps.lua (70%) rename {scripts => development/tests}/test-keymaps.sh (83%) delete mode 100755 test-personal-wiki.sh diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index e9f9964..b7df8e4 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -93,6 +93,6 @@ jobs: sudo apt-get install -y --no-install-recommends vim vim --version | head -1 - name: run Neovim keymap harness - run: ./scripts/test-keymaps.sh + run: ./development/tests/test-keymaps.sh - name: run Vim keymap harness - run: ./scripts/test-keymaps-vim.sh + run: ./development/tests/test-keymaps-vim.sh diff --git a/README.md b/README.md index 0138a86..9b47540 100644 --- a/README.md +++ b/README.md @@ -122,8 +122,8 @@ no extra plugin. ### Try it without touching your config ```sh -./start-nvim.sh # spawns Neovim against an isolated sample wiki -./start-vim.sh # same, for plain Vim (clones vim-lsp on first run) +./development/start-nvim.sh # spawns Neovim against an isolated sample wiki +./development/start-vim.sh # same, for plain Vim (clones vim-lsp on first run) ``` Both scripts build the LSP binary, seed a scratch wiki, and launch the diff --git a/SPEC.md b/SPEC.md deleted file mode 100644 index 3da8bfe..0000000 --- a/SPEC.md +++ /dev/null @@ -1,1425 +0,0 @@ -# nuwiki — Project Specification - -> Last updated: 2026-05-11 -> Status: v1.0 (Phases 0–10) shipped; v1.1 (Phases 11–18) in design - -> ### Versioning convention -> -> - **v1.0** — the parsing + highlighting + navigation foundation. LSP server -> speaks the spec's §6.9 method set; editor glue provides install + setup -> + health check. -> - **v1.1** — full vimwiki replacement. Adds tags, diary, the -> text-manipulation command surface (toggle checkbox, change list symbol, -> format table, rename page, …), multi-wiki, HTML export commands, -> link-health diagnostics, and the keymap/text-object layer that matches -> vimwiki's daily-authoring ergonomics. - ---- - -## 1. Overview - -nuwiki is a Vim/Neovim plugin that provides full vimwiki syntax support, implemented as a Rust-based Language Server (LSP). It is a spiritual successor to vimwiki, architecturally distinct from it, designed for correctness, performance, and extensibility. - -**Goals:** -- Full vimwiki syntax support (highlighting, navigation, link following, diagnostics) -- First-class support for both Vim 9+ and Neovim 0.5+ -- Installable via standard plugin managers (lazy.nvim, vim-plug, Dein) -- Extensible to Markdown syntax without changes to core architecture -- Independently testable core library with no editor dependencies - -**Non-goals (v1.0):** -- MediaWiki syntax support -- Multi-wiki configurations *(addressed in v1.1, see §12)* -- Incremental / tree-sitter parsing -- Browser-based or WASM build - -**v1.1 scope** *(see §12 for the full plan)*: -- Vimwiki-parity command surface (`:Vimwiki*` compat aliases over LSP - `executeCommand`) -- Tags, diary, link health, HTML export commands, multi-wiki, folding - ---- - -## 2. Repository - -| Property | Value | -|---|---| -| License | Dual MIT/Apache-2.0 | -| MSRV | Rust 1.83 (stable-2) | -|---|---| -| URL | `https://code.gfran.co/gffranco/nuwiki` | -| Version control | Gitea | -| CI/CD | Gitea Actions | - ---- - -## 3. Naming Conventions - -| Context | Name | -|---|---| -| GitHub/Gitea repo | `nuwiki` | -| Cargo workspace | `nuwiki` | -| Core library crate | `nuwiki-core` | -| LSP bridge crate | `nuwiki-lsp` | -| Binary crate | `nuwiki-ls` | -| Vim plugin entry | `plugin/nuwiki.vim` | -| Lua module | `require('nuwiki')` | -| VimL autoload | `nuwiki#lsp#start()` | -| Vim help file | `doc/nuwiki.txt` → `:h nuwiki` | -| Release binary | `nuwiki-ls-{version}-{target}.tar.gz` | - ---- - -## 4. Technology Stack - -| Concern | Choice | Rationale | -|---|---|---| -| Implementation language | Rust | Performance, type safety, single binary distribution, ideal for AST modelling | -| Editor integration | LSP over stdio | Works in both Vim and Neovim without a shared scripting language | -| LSP server library | `tower-lsp` | Async, tokio-based, higher-level than `lsp-server`; easier to start with | -| Parser library | None — hand-rolled recursive descent | Tokens are span-bearing structs that fit awkwardly into `winnow`/`nom` combinator style; resilience and error recovery are explicit in the parser code. Originally specified as `winnow`; revisited in Phase 4. | -| Cargo edition | 2021 | Current standard; required for resolver v2 | -| Cargo resolver | v2 | Required for edition 2021 workspaces | -| Vim glue layer | VimL (`plugin/nuwiki.vim`, `autoload/`) | Universal entry point for all plugin managers | -| Neovim glue layer | Lua (`lua/nuwiki/`) | First-class Neovim API access via `vim.lsp.start()` | - ---- - -## 5. Repository Layout - -``` -nuwiki/ -│ -├── Cargo.toml # Rust workspace root -├── Cargo.lock -│ -├── crates/ -│ ├── nuwiki-ls/ # Binary crate — thin main.rs, starts stdio LSP server -│ │ ├── Cargo.toml -│ │ └── src/main.rs -│ │ -│ ├── nuwiki-core/ # Library crate — parser, AST, renderer (no editor deps) -│ │ ├── Cargo.toml -│ │ └── src/ -│ │ ├── lib.rs -│ │ ├── syntax/ -│ │ │ ├── mod.rs -│ │ │ ├── registry.rs -│ │ │ └── vimwiki/ -│ │ │ ├── mod.rs -│ │ │ ├── lexer.rs -│ │ │ └── parser.rs -│ │ ├── ast/ -│ │ │ ├── mod.rs -│ │ │ ├── block.rs -│ │ │ ├── inline.rs -│ │ │ └── link.rs -│ │ └── render/ -│ │ ├── mod.rs -│ │ └── html.rs -│ │ -│ └── nuwiki-lsp/ # Library crate — LSP protocol bridge -│ ├── Cargo.toml -│ └── src/lib.rs -│ -├── plugin/ # Universal Vim/Neovim entry point -│ └── nuwiki.vim -│ -├── lua/ # Neovim-specific Lua layer -│ └── nuwiki/ -│ ├── init.lua -│ ├── config.lua -│ ├── lsp.lua -│ └── install.lua # Binary download / build-from-source logic -│ -├── autoload/ # Lazy-loaded VimL (Vim compat layer) -│ └── nuwiki/ -│ └── lsp.vim -│ -├── ftdetect/ # Filetype detection for .wiki files -│ └── nuwiki.vim -│ -├── ftplugin/ # Per-filetype buffer settings -│ └── nuwiki.vim -│ -├── syntax/ # Static fallback syntax highlighting (no LSP required) -│ └── nuwiki.vim -│ -├── doc/ # Vim help documentation -│ └── nuwiki.txt -│ -├── scripts/ -│ └── download_bin.vim # VimL binary download (used by Dein/vim-plug build hooks) -│ -└── .gitea/ - └── workflows/ - ├── ci.yaml # Lint, test, fmt check on every push/PR - └── release.yaml # Cross-compile + release on v* tag -``` - ---- - -## 6. Architecture - -### 6.1 Layer Model - -``` -┌─────────────────────────────────────────────────────┐ -│ Consumer Layer │ -│ (editor highlight, HTML export, TOC, …) │ -└────────────────────────┬────────────────────────────┘ - │ operates on - ▼ -┌─────────────────────────────────────────────────────┐ -│ AST (nuwiki-core, shared) │ -│ Document > Block nodes > Inline nodes │ -└────────────────────────┬────────────────────────────┘ - │ produced by - ▼ -┌─────────────────────────────────────────────────────┐ -│ Parser — syntax-specific (nuwiki-core) │ -│ Registered per syntax; receives TokenStream │ -└────────────────────────┬────────────────────────────┘ - │ consumes - ▼ -┌─────────────────────────────────────────────────────┐ -│ Lexer — syntax-specific (nuwiki-core) │ -│ Registered per syntax; receives raw text │ -└────────────────────────┬────────────────────────────┘ - │ reads - ▼ - Raw text input -``` - -### 6.2 Crate Dependency Rules - -``` -nuwiki-ls → nuwiki-lsp → nuwiki-core -``` - -- `nuwiki-core` must never depend on `nuwiki-lsp` or `nuwiki-ls` -- `nuwiki-lsp` must never depend on `nuwiki-ls` -- The VimL and Lua editor layers must never contain logic — pure wiring only - -### 6.3 Syntax Plugin Interface - -Every syntax (vimwiki, future markdown) implements: - -``` -SyntaxPlugin - id: string -- "vimwiki" | "markdown" - display_name: string - file_extensions: string[] -- [".wiki"] | [".md"] - lexer: fn(text) → TokenStream - parser: fn(TokenStream) → DocumentNode -``` - -Registry: -``` -SyntaxRegistry - register(plugin: SyntaxPlugin) - get(id: &str) → Option<&SyntaxPlugin> - detect_from_extension(ext: &str) → Option<&SyntaxPlugin> -``` - -All plugins must be `Send + Sync` (held in the LSP server registry). - -### 6.4 AST Node Types - -#### Document -``` -DocumentNode - children: Vec - metadata: PageMetadata -- title, nohtml, template, date -``` - -#### Block Nodes -``` -HeadingNode level: 1–6 | children: Vec | centered: bool -ParagraphNode children: Vec -HorizontalRuleNode -BlockquoteNode children: Vec -PreformattedNode content: String | language: Option -MathBlockNode content: String | environment: Option -ListNode ordered: bool | symbol: ListSymbol | items: Vec -DefinitionListNode items: Vec -TableNode rows: Vec | has_header: bool -CommentNode content: String -ErrorNode raw: String | message: String -- resilient parse failure -``` - -#### Inline Nodes -``` -TextNode content: String -BoldNode children: Vec -ItalicNode children: Vec -BoldItalicNode children: Vec -StrikethroughNode children: Vec -CodeNode content: String -SuperscriptNode children: Vec -SubscriptNode children: Vec -MathInlineNode content: String -KeywordNode keyword: Keyword -- TODO|DONE|STARTED|FIXME|FIXED|XXX -ColorNode color: String | children: Vec -WikiLinkNode target: LinkTarget | description: Option> -ExternalLinkNode url: String | description: Option> -TransclusionNode url: String | alt: Option | attrs: HashMap -RawUrlNode url: String -``` - -#### Supporting Types -``` -ListItemNode - symbol: ListSymbol - level: usize - checkbox: Option - children: Vec - sublist: Option - -DefinitionItemNode - term: Option> - definitions: Vec> - -TableRowNode cells: Vec | is_header: bool -TableCellNode children: Vec | col_span: bool | row_span: bool - -LinkTarget - kind: LinkKind -- Wiki|Interwiki|Diary|File|Local|Raw|AnchorOnly - path: Option - wiki_index: Option - wiki_name: Option - anchor: Option - is_absolute: bool - is_directory: bool - -ListSymbol = Dash | Star | Hash | Numeric | NumericParen - | AlphaParen | AlphaUpperParen | RomanParen | RomanUpperParen -CheckboxState = Empty | Quarter | Half | ThreeQuarters | Done | Rejected -Keyword = Todo | Done | Started | Fixme | Fixed | Xxx -``` - -### 6.5 Span / Source Location - -Every AST node carries a `Span`: - -```rust -struct Span { - start: Position, - end: Position, -} - -struct Position { - line: u32, -- 0-indexed - column: u32, -- 0-indexed, byte offset within line - offset: usize, -- absolute byte offset from document start -} -``` - -Spans are required for LSP diagnostics, semantic tokens, and go-to-definition. - -### 6.6 Lexer Strategy - -- **Two-pass:** block-level pass first, then inline pass within each block -- **Token types:** syntax-specific (`VimwikiToken`) — not shared across syntaxes -- **TokenStream:** eager `Vec` wrapped in a `TokenStream` newtype (changeable to lazy later) -- Operates on Rust `char`s for correctness; spans stored as byte offsets - -### 6.7 Parser Strategy - -- Uses `winnow` parser combinator library -- Resilient: on malformed input, emits `ErrorNode` and continues — never fails the whole document -- Inline marker precedence rules documented explicitly in code and tests - -### 6.8 Renderer - -- `Renderer` trait: writer-based (`fn render(&self, doc: &DocumentNode, w: &mut dyn Write)`) -- `HtmlRenderer` is the first implementation -- Link resolution injected as a callback at construction time -- Template support: minimal `{{content}}` / `{{title}}` token substitution -- CSS: linked external stylesheet (matches vimwiki convention) - -### 6.9 LSP Features - -| Feature | LSP Method | -|---|---| -| Syntax highlighting | `textDocument/semanticTokens/full` + `/range` | -| Diagnostics (broken links, parse errors) | `textDocument/publishDiagnostics` | -| Follow link / go to definition | `textDocument/definition` | -| Backlinks | `textDocument/references` | -| TOC / outline | `textDocument/documentSymbol` | -| Link preview | `textDocument/hover` | -| Link autocomplete | `textDocument/completion` (trigger: `[[`) | -| Page rename | `workspace/rename` | -| Workspace search | `workspace/symbol` | - -### 6.10 Document Store - -``` -DocumentStore: Arc> - -DocumentState - text: String - ast: DocumentNode - version: i32 -``` - -Full re-parse on every `didOpen` / `didChange`. Incremental parsing deferred post-v1. - -### 6.11 UTF-16 / Position Encoding - -LSP positions negotiated as UTF-8 (`positionEncoding = "utf-8"`) during `initialize` where the client supports LSP 3.17+. UTF-16 conversion fallback implemented in the LSP layer for older clients. `nuwiki-core` is always UTF-8 / byte-offset internally. - ---- - -## 7. Editor Integration - -### 7.1 Plugin Manager Installation - -**lazy.nvim:** -```lua -{ - "gffranco/nuwiki", - build = "lua require('nuwiki').install()", - ft = { "vimwiki" }, - opts = {}, -} -``` - -**vim-plug:** -```vim -Plug 'gffranco/nuwiki', { 'do': 'vim -e -s -c "source scripts/download_bin.vim" -c "q"' } -``` - -**Dein:** -```vim -call dein#add('gffranco/nuwiki', { - \ 'build': 'vim -e -s -c "source scripts/download_bin.vim" -c "q"' - \ }) -``` - -### 7.2 Binary Distribution - -- Pre-built binaries published as Gitea release assets -- Named: `nuwiki-ls-{version}-{target}.tar.gz` -- Downloaded at install time by `lua/nuwiki/install.lua` or `scripts/download_bin.vim` -- Fallback: `cargo build --release` if download fails or `g:nuwiki_build_from_source = 1` -- Binary installed to `{plugin_dir}/bin/nuwiki-ls[.exe]` - -### 7.3 Editor Detection - -```vim -" plugin/nuwiki.vim -if has('nvim') - lua require('nuwiki').setup() -else - call nuwiki#lsp#start() -endif -``` - -### 7.4 Vim LSP Client Preference Order (Vim only) - -1. `vim-lsp` (matoto/vim-lsp) -2. `coc.nvim` -3. Error message if neither found - -### 7.5 User Config Schema - -```lua -require('nuwiki').setup({ - wiki_root = "~/vimwiki", -- root directory of the wiki - file_extension = ".wiki", -- file extension to associate - syntax = "vimwiki", -- "vimwiki" | "markdown" (future) - log_level = "warn", -- "error"|"warn"|"info"|"debug" -}) -``` - -### 7.6 Health Check - -`:checkhealth nuwiki` verifies: -- Binary exists at `{plugin_dir}/bin/nuwiki-ls` -- Binary is executable and responds to `--version` -- LSP client is running -- Filetype detection works for `.wiki` files - ---- - -## 8. CI/CD - -### 8.1 Runner Setup - -| Property | Value | -|---|---| -| System | Gitea Actions (`act_runner`) | -| Mode | Docker (jobs run in containers) | -| Docker access | Host socket mounted (`/var/run/docker.sock`) | -| Internet access | Yes | - -### 8.2 Workflow Files - -**`.gitea/workflows/ci.yaml`** — triggers on every push and PR: -- `cargo fmt --check` -- `cargo clippy -- -D warnings` -- `cargo test --workspace` - -**`.gitea/workflows/release.yaml`** — triggers on `v*` tag push: -- Cross-compile for all 4 Linux targets using `cross` -- Package binaries as `.tar.gz` -- Create Gitea release and upload assets via REST API using `RELEASE_TOKEN` secret -- crates.io publish is deferred — workflow ships the Gitea release only. Re-enable by adding a `cargo publish -p nuwiki-core` job once the crate is ready for publication. - -### 8.3 Build Targets - -| Target | Built by | -|---|---| -| `x86_64-unknown-linux-gnu` | CI (`cross`) | -| `aarch64-unknown-linux-gnu` | CI (`cross`) | -| `x86_64-unknown-linux-musl` | CI (`cross`) | -| `aarch64-unknown-linux-musl` | CI (`cross`) | -| `x86_64-apple-darwin` | Manual | -| `aarch64-apple-darwin` | Manual | -| `x86_64-pc-windows-msvc` | Manual | - -### 8.4 Release Secrets - -| Secret | Purpose | -|---|---| -| `RELEASE_TOKEN` | Gitea personal access token for creating releases and uploading assets | -| ~~`CARGO_REGISTRY_TOKEN`~~ | ~~crates.io API token for publishing `nuwiki-core`~~ — deferred (see §8.2) | - -### 8.5 `actions/cache` Configuration - -Requires `act_runner`'s `config.yaml` to have the cache server host set to the runner host's LAN IP so job containers can reach it. - -### 8.6 Breaking Change Policy - -A semver-breaking change (`v0.x` → `v0.x+1` or `v1.x` → `v2.0`) is defined as any of: -- AST node type additions, removals, or field changes in `nuwiki-core` -- `SyntaxPlugin` or `Renderer` trait signature changes -- User config schema key removals or type changes -- Removed LSP capabilities - ---- - -## 9. Vimwiki Syntax Feature Checklist - -### Typefaces -- [ ] Bold: `*text*` -- [ ] Italic: `_text_` -- [ ] Bold italic: `_*text*_` / `*_text_*` -- [ ] Strikethrough: `~~text~~` -- [ ] Inline code: `` `text` `` -- [ ] Superscript: `super^script^` -- [ ] Subscript: `sub,,script,,` -- [ ] Keywords: `TODO` `DONE` `STARTED` `FIXME` `FIXED` `XXX` - -### Links -- [ ] Plain wikilink: `[[Target]]` -- [ ] Described wikilink: `[[Target|Description]]` -- [ ] Subdirectory wikilink: `[[dir/Page]]` -- [ ] Root-relative wikilink: `[[/Page]]` -- [ ] Filesystem-absolute wikilink: `[[//path]]` -- [ ] Subdirectory link: `[[dir/]]` -- [ ] Interwiki numbered: `[[wiki1:Page]]` -- [ ] Interwiki named: `[[wn.Name:Page]]` -- [ ] Diary link: `[[diary:YYYY-MM-DD]]` -- [ ] Anchor-only link: `[[#Anchor]]` -- [ ] Wikilink with anchor: `[[Page#Anchor]]` -- [ ] Raw URLs: `https://…` `mailto:…` `ftp://…` -- [ ] External file link: `[[file:path]]` / `[[local:path]]` -- [ ] Transclusion: `{{URL}}` with optional alt and attrs -- [ ] Thumbnail link: `[[imgURL|{{thumbURL}}]]` - -### Headers -- [ ] Levels 1–6: `= H1 =` … `====== H6 ======` -- [ ] Centered header (leading whitespace before `=`) - -### Lists -- [ ] Unordered: `-` and `*` -- [ ] Ordered: `1.` `1)` `a)` `A)` `i)` `I)` `#` -- [ ] Nested and mixed types -- [ ] Multi-line items (indentation continuation) -- [ ] Definition lists: `Term:: Definition` -- [ ] Checkboxes: `[ ]` `[.]` `[o]` `[O]` `[X]` `[-]` - -### Tables -- [ ] Basic `|`-delimited table -- [ ] Header row separator: `|---|` -- [ ] Column span: `>` -- [ ] Row span: `\/` -- [ ] Inline formatting inside cells - -### Preformatted Text -- [ ] Fenced block: `{{{ … }}}` -- [ ] Optional language/class on opening fence - -### Mathematical Formulae -- [ ] Inline math: `$ … $` -- [ ] Block display math: `{{$ … }}$` -- [ ] Block environment math: `{{$%env% … }}$` - -### Blockquotes -- [ ] 4-space indent blockquote -- [ ] `>` prefix blockquote - -### Comments -- [ ] Single-line: `%% …` -- [ ] Multi-line: `%%+ … +%%` - -### Horizontal Rule -- [ ] Four or more dashes: `----` - -### Placeholders -- [ ] `%title ` -- [ ] `%nohtml` -- [ ] `%template ` -- [ ] `%date ` - ---- - -## 10. Implementation Phases - -| Phase | Name | Key Output | -|---|---|---| -| 0 | Scaffolding | Compiling empty workspace + CI skeleton | -| 1 | Core AST | All node types, spans, Visitor trait | -| 2 | Syntax Plugin Interface | `Lexer`/`Parser`/`SyntaxPlugin` traits, `SyntaxRegistry` | -| 3 | Vimwiki Lexer | `VimwikiToken`, two-pass lexer with spans | -| 4 | Vimwiki Parser | Full AST from token stream, error recovery | -| 5 | Renderer | `Renderer` trait + `HtmlRenderer` | -| 6 | LSP Foundation | `didOpen`/`didChange`, diagnostics, document symbols | -| 7 | Semantic Tokens | AST → LSP semantic token stream | -| 8 | Navigation | Definition, references, hover, completion, workspace index | -| 9 | Editor Glue | Installable plugin, health check, binary download | -| 10 | CI/CD | Automated lint/test/release pipeline | -| **— v1.1: full vimwiki replacement (see §12) —** | | | -| 11 | Plumbing prerequisites | Server config receipt, `WorkspaceEditBuilder` + span helpers, closed-doc loader, diagnostic source chain, `Wiki` aggregate (single-entry to start) | -| 12 | Tags | `:tag:` lex + parse, `TagNode`, index, tag-as-anchor resolution | -| 13 | Workspace edits + executeCommand | `workspace/rename` with cross-doc link rewrite, `DeleteFile` ops, `executeCommand` infrastructure | -| 14 | List & table edit commands | Server commands returning `WorkspaceEdit` for checkbox toggle, list-symbol/level change, renumber, table align, column move, table insert | -| 15 | Link health + link/TOC generation | Broken-link diagnostics, `nuwiki.toc.generate`, `nuwiki.links.generate`, `nuwiki.workspace.checkLinks` | -| 16 | Diary | Diary subpath, today/yesterday/tomorrow open commands, diary index generation, calendar hook API | -| 17 | HTML export commands | `nuwiki.export.*` commands, `%template` resolution, CSS file management, auto-export option | -| 18 | Multi-wiki | `wikis = [...]` config shape, multi-entry `Wiki` aggregate (data structures already landed in Phase 11), URI → wiki resolution, wiki-picker commands | -| 19 | Editor glue v2 | Full `:Vimwiki*` command compat layer, default keymaps, text objects (`ah`/`aH`/`a\\`/`ac`/`al`), folding via LSP `foldingRange` + `foldexpr` fallback | - ---- - -## 11. Pending Decisions - -These decisions are required before or during the phase indicated. - -| # | Decision | Needed by | Options | Notes | -|---|---|---|---|---| -| ~~P1~~ | ~~**License**~~ | ~~Phase 0~~ | ✅ **Dual MIT/Apache-2.0** | | -| ~~P2~~ | ~~**MSRV**~~ | ~~Phase 0~~ | ✅ **stable-2 (Rust 1.83)** | | -| ~~P3~~ | ~~**String representation in AST**~~ | ~~Phase 1~~ | ✅ **`String` (owned)** | | -| ~~P4~~ | ~~**Visitor pattern**~~ | ~~Phase 1~~ | ✅ **Defined in Phase 1** | Open-recursion `Visitor` trait + `walk_*` helpers | -| ~~P5~~ | ~~**Minimum Neovim version**~~ | ~~Phase 9~~ | ✅ **Neovim 0.11+** | Server registered via `vim.lsp.config{}` + `vim.lsp.enable`; a `vim.lsp.start` autocmd fallback is wired up but only fires when the declarative API is unavailable. | -| ~~P6~~ | ~~**Minimum Vim version**~~ | ~~Phase 9~~ | ✅ **Vim 9.1+** | autoload glue assumes 9.1 idioms; uses `vim-lsp` first, then falls back to `coc.nvim`. | -| ~~P7~~ | ~~**Semantic token type mapping**~~ | ~~Phase 7~~ | ✅ **Custom vimwiki-specific token types** | ~20 types (`vimwikiHeading`, `vimwikiBold`, …) + `level1`..`level6` + `centered` modifiers. Phase 9 ships default highlight groups in `syntax/nuwiki.vim` and the Lua glue. | -| ~~P8~~ | ~~**Workspace indexing strategy**~~ | ~~Phase 8~~ | ✅ **Lazy + background with progress** | Initial scan runs as a background tokio task; nav features answer with partial data until complete; progress reported via `window/workDoneProgress`. | -| ~~P9~~ | ~~**macOS runner**~~ | ~~Phase 10~~ | ✅ **Stay manual** | Release workflow cross-compiles the 4 Linux targets via `cross`; macOS + Windows binaries are produced ad hoc by maintainers and uploaded to the same Gitea release. Revisit when a Mac runner is available. | -| **v1.1 decisions** | | | | | -| P10 | **Command namespace** | Phase 13 | `:Vimwiki*` only · `:Nuwiki*` only · both | Both → easy migration *and* discoverable native commands; one canonical name + alias. | -| P11 | **Tag syntax** | Phase 12 | Strict vimwiki `:tag:` only · Add markdown `#tag` as a second flavour | Strict keeps parity; adding `#tag` collides with hash list markers and ordered-list `#`. | -| P12 | **List-edit transport** | Phase 14 | Server returns `WorkspaceEdit` from `executeCommand` · Editor-side text manipulation in VimL/Lua | `WorkspaceEdit` keeps logic in Rust + testable, but adds latency per keystroke for ``. | -| P13 | **Multi-wiki config shape** | Phase 18 | Lua list (Neovim-native) · JSON config file (cross-editor) | Lua is ergonomic for Neovim; JSON works for Vim + arbitrary clients. | -| ~~P14~~ | ~~**Folding mechanism**~~ | ~~Phase 19~~ | ✅ **LSP `textDocument/foldingRange` (primary) + `foldexpr` fallback** | Server already has the AST + heading boundaries — duplicating that knowledge as regex in VimL would be lossy. `foldexpr` ships in `ftplugin` as a fallback for when the server hasn't attached. | -| P15 | **Diary path scheme** | Phase 16 | Fixed `/diary/` · Per-wiki configurable `diary_rel_path` | Vimwiki has `diary_rel_path`; matching keeps migrations clean. | -| P16 | **Backwards-compat for v1.0 config** | Phase 18 | Preserve old single-`wiki_root` shape · Force migration to `wikis = [...]` | Single-wiki should keep working without re-config; `wiki_root` becomes sugar for `wikis = [{ root = wiki_root }]`. | -| P17 | **Markdown syntax in v1.1** | Phase 12+ | Include · Defer to v1.2 | Architecture already supports it (`SyntaxPlugin`); cost is parser work + per-syntax differences in commands. | -| P18 | **Server-config transport** | Phase 11 | `initializationOptions` only · `workspace/didChangeConfiguration` only · both | Both → simple boot path *and* live reload. Initialise at startup, accept change notifications afterwards. | - ---- - -## 12. v1.1 — Full vimwiki Replacement - -v1.0 shipped the parsing + highlighting + navigation foundation. A vimwiki -user transitioning to nuwiki today gets accurate syntax recognition and -modern LSP nav, but loses the dense interactive layer that makes vimwiki -ergonomic for daily authoring: ≈40 `:Vimwiki*` commands, ≈60 keymaps, the -diary, the tag system, link health, HTML export commands, and multi-wiki -configuration. - -v1.1 closes that gap. The architecture is unchanged — every new editing -operation lands as an LSP `executeCommand` returning a `WorkspaceEdit`, so -the `nuwiki-core` AST stays the single source of truth and the editor glue -stays pure wiring. - -### 12.1 Stability commitment - -v1.1 is additive. The following stay backward-compatible: - -- `nuwiki-core` public AST + Visitor (additions only; no field renames) -- LSP capabilities advertised in v1.0 -- `Renderer` trait + `HtmlRenderer` default output shape -- v1.0 user-config keys (`wiki_root`, `file_extension`, `syntax`, `log_level`) - -New AST nodes (e.g. `TagNode`) and new LSP capabilities are opt-in by -inspection. The Visitor trait gains default-bodied `visit_*` methods for new -node kinds so existing Visitor implementations don't break. - -### 12.2 Plumbing prerequisites (Phase 11) - -Cross-cutting infrastructure that every later v1.1 phase depends on. -Landing it as Phase 11 — before any user-visible feature — keeps Phases -12–19 mechanical instead of full of "oh we also need to refactor X". - -#### 12.2.1 Server config receipt (P18) - -Per P18: both `initializationOptions` and `workspace/didChangeConfiguration` -land in Phase 11. - -- `initialize.initializationOptions` carries the full v1.1 config (§12.11 - schema) verbatim. Parsed into a server-side `Config` struct. -- `workspace/didChangeConfiguration` re-applies the config for live reload. -- Every handler reads from a single `Arc>` field on - `Backend`. - -```rust -pub struct Config { - pub log_level: LogLevel, - pub diagnostic: DiagnosticConfig, - pub wikis: Vec, // always ≥ 1; v1.0-shape desugars - // … -} - -impl Config { - pub fn from_init_options(value: serde_json::Value) -> Self; - pub fn from_v1_0_compat(legacy: LegacyConfig) -> Self; // P16 path -} -``` - -#### 12.2.2 `WorkspaceEditBuilder` + span helpers - -New module `crates/nuwiki-lsp/src/edits.rs`: - -```rust -pub fn text_edit_replace(span: Span, replacement: String, text: &str, utf8: bool) -> TextEdit; -pub fn text_edit_insert(pos: Position, content: String) -> TextEdit; -pub fn text_edit_delete(span: Span, text: &str, utf8: bool) -> TextEdit; -pub fn op_rename(old: Url, new: Url) -> DocumentChangeOperation; -pub fn op_delete(uri: Url) -> DocumentChangeOperation; - -pub struct WorkspaceEditBuilder { - changes: HashMap>, - file_ops: Vec, -} -impl WorkspaceEditBuilder { - pub fn edit(&mut self, uri: Url, edit: TextEdit) -> &mut Self; - pub fn file_op(&mut self, op: DocumentChangeOperation) -> &mut Self; - pub fn build(self) -> WorkspaceEdit; -} -``` - -All v1.1 commands that produce `WorkspaceEdit` (Phases 13–17) go through -the builder. Standardises UTF-8/UTF-16 conversion and document-change -ordering. - -#### 12.2.3 Closed-doc loader - -```rust -pub struct DocSnapshot { - pub text: String, - pub ast: DocumentNode, - pub in_memory: bool, // held in `documents` DashMap -} - -impl Backend { - pub async fn read_or_open(&self, uri: &Url) -> io::Result; -} -``` - -If the URI is in `documents`, return live state. Otherwise read the file -from disk and re-parse so cross-document operations (Phase 13 -`workspace/rename`) can compute accurate spans + UTF-16 conversions -instead of trusting stale `WorkspaceIndex` data. - -#### 12.2.4 Diagnostic source chain - -Today's `ast_diagnostics(&ast, &text, utf8)` is replaced by a composable -collector: - -```rust -pub fn collect_diagnostics( - ast: &DocumentNode, - text: &str, - index: Option<&WorkspaceIndex>, - page_name: Option<&str>, - cfg: &DiagnosticConfig, - utf8: bool, -) -> Vec; -``` - -Composes: - -1. Parse errors (`ErrorNode` walk; already shipped) -2. Broken-link warnings (Phase 15) -3. (future) other sources - -Each gated by `cfg`. Per-source severity is config-driven. - -#### 12.2.5 `Wiki` aggregate - -Single-wiki state becomes a `Wiki` aggregate, even though Phase 11 only -ever builds one entry. Phase 18 then changes config shape, not data -structures. - -```rust -pub struct WikiId(u32); -pub struct WikiConfig { - pub name: String, - pub root: PathBuf, - pub file_extension: String, - pub syntax: String, - pub diary_rel_path: String, - pub html_path: PathBuf, - pub template_path: PathBuf, - // … all per-wiki options -} -pub struct Wiki { - pub id: WikiId, - pub config: WikiConfig, - pub index: Arc>, -} - -struct Backend { - // … - wikis: Arc>>, -} - -impl Backend { - fn resolve_uri_to_wiki(&self, uri: &Url) -> Option; // longest-prefix root match - fn wiki(&self, id: WikiId) -> Option>; - fn default_wiki(&self) -> Option>; -} -``` - -`WorkspaceIndex` itself doesn't gain any new responsibilities in this -phase — it just lives inside a `Wiki`. All v1.0 handlers that today read -the single `index` field get refactored to call `default_wiki()` (or -`resolve_uri_to_wiki()` where the URI is known). - -#### 12.2.6 What does *not* change - -Explicitly out of scope for Phase 11: - -- AST shape (no new node kinds — Tags wait for Phase 12) -- LSP capability set (no new providers — capabilities expand from Phase 13) -- HtmlRenderer surface (Phase 17 extends the template substitution set) -- Anything user-visible (this phase is invisible to clients) - -#### 12.2.7 Tests - -Phase 11 is fully covered by unit tests on the new helpers: - -- `WorkspaceEditBuilder`: multi-uri accumulation, file-op ordering, - UTF-16 conversion for non-ASCII spans. -- `read_or_open`: live doc returns `in_memory: true`; missing path - returns `Err`; on-disk path returns `in_memory: false` with parsed AST. -- `collect_diagnostics`: parse errors emitted; link-health stub returns - empty until Phase 15 wires it. -- `Config::from_init_options`: legacy single-wiki shape desugars to - one-entry `wikis = [...]`. -- `Backend::resolve_uri_to_wiki`: longest-prefix match wins. - -### 12.3 Tags (Phase 12) - -#### Syntax - -Vimwiki tags are colon-delimited sequences of non-space characters: - -``` -:tag-one:tag-two:other-tag: -``` - -Placement rules (matching vimwiki): - -- On line 1 or 2 of a file → file-level tag -- Within 2 lines after a `= Heading =` → header-level tag -- Otherwise → standalone anchor at the source line - -#### AST additions - -``` -TagNode (block) - span: Span - tags: Vec - scope: TagScope -- File | Heading(idx) | Standalone - -PageMetadata (extend) - tags: Vec -- file-level tags, accumulated -``` - -`TagNode` slots into `BlockNode` as a new variant. The Visitor gains -`visit_tag` (default-bodied). - -#### Indexing - -`WorkspaceIndex` extends `IndexedPage` with: - -``` -IndexedPage (extend) - tags: Vec - -TagInfo - name: String - scope: TagScope - span: Span -``` - -Plus a `tags_by_name: HashMap>` reverse map. The -existing backlink machinery in §6 stays as-is. - -#### LSP behaviour - -- `tags-as-anchors` extends `textDocument/definition`: `[[Page#some-tag]]` - resolves to the tag's location in the target page (in addition to headings). -- `workspace/symbol` results include tags (with `SymbolKind::PROPERTY` or a - custom kind). -- New `executeCommand` operations: `nuwiki.tags.search`, - `nuwiki.tags.generateLinks(tag, ...)`, `nuwiki.tags.rebuild` (force re-index). - -### 12.4 Workspace edits + `executeCommand` (Phase 13) - -> **Note on closed documents.** When `workspace/rename` rewrites incoming -> links across pages that are *not* open in the editor, the server uses the -> Phase 11 closed-doc loader (`read_or_open`) to re-parse them on demand. -> Stored `WorkspaceIndex` spans are byte-accurate but may have drifted if -> the on-disk file changed outside the editor; the re-parse guarantees we -> emit `TextEdit`s against the current source text and convert to LSP -> positions correctly (esp. for non-ASCII content under UTF-16 encoding). - -#### Capability - -``` -ServerCapabilities { - execute_command_provider: Some(ExecuteCommandOptions { - commands: vec!["nuwiki.*", …], - }), - rename_provider: Some(true), - workspace: Some(WorkspaceServerCapabilities { - file_operations: Some(FileOperationOptions { - will_rename: Some(...), - did_rename: Some(...), - will_delete: Some(...), - did_delete: Some(...), - }), - }), -} -``` - -#### `workspace/rename` semantics - -When a user renames `Page A` → `Page B`: - -1. Server emits a `WorkspaceEdit` containing a `RenameFile` op (`A.wiki` → - `B.wiki`). -2. For every page in the index that links to `A`, the edit also rewrites - `[[A]]` → `[[B]]` (preserving description, anchor, kind). -3. Anchors don't trigger renames — `[[A#anchor]]` follows. - -#### Custom commands router - -A single `executeCommand` handler dispatches by command name. Every command -takes either: - -``` -{ uri: Url, position?: Position, range?: Range, ...args } -``` - -and returns a `WorkspaceEdit` (which the client applies via -`workspace/applyEdit`). - -### 12.5 List & table editing commands (Phase 14) - -| Command | Operates on | Result | -|---|---|---| -| `nuwiki.list.toggleCheckbox` | list item under cursor | `[ ]` ↔ `[X]`, propagates parent state | -| `nuwiki.list.cycleCheckbox` | list item | `[ ]` → `[.]` → `[o]` → `[O]` → `[X]` cycle | -| `nuwiki.list.rejectCheckbox` | list item | toggle `[-]` rejected state | -| `nuwiki.list.changeSymbol` | list item, args: `symbol: ListSymbol`, `whole_list: bool` | rewrite marker, renumber if numeric | -| `nuwiki.list.changeLevel` | list item, args: `delta: i32`, `whole_subtree: bool` | re-indent + adjust child levels | -| `nuwiki.list.renumber` | list, args: `whole_file: bool` | re-sequence numeric markers | -| `nuwiki.list.removeDone` | range or current list | remove every `[X]` / `[-]` item and its checked children | -| `nuwiki.list.nextTask` | document | navigate to next unfinished task (returns `Location`, not edit) | -| `nuwiki.table.align` | table under cursor | reformat columns to max width | -| `nuwiki.table.moveColumn` | cell, args: `dir: "left"\|"right"` | swap column with neighbour | -| `nuwiki.table.insert` | cursor, args: `cols, rows` | insert blank table at cursor | -| `nuwiki.heading.addLevel` | heading | promote (`==` → `===`) | -| `nuwiki.heading.removeLevel` | heading | demote | -| `nuwiki.link.normalize` | word/selection | convert to `[[wikilink]]`, add description if missing | -| `nuwiki.link.pasteWikilink` | cursor | paste current page name as an absolute wikilink | -| `nuwiki.link.pasteUrl` | cursor | paste the corresponding HTML output URL | -| `nuwiki.colorize` | range, args: `color: String` | wrap in colour tag per `color_tag_template` | - -All operations resolve to a textual diff over the existing source so the -AST stays the source of truth. None of them rely on editor-side state. - -### 12.6 Link health + TOC/index generation (Phase 15) - -#### Broken-link diagnostics - -A new diagnostic source `nuwiki.link` runs after each parse: - -- Wiki target → not in `WorkspaceIndex` → severity from config (default - `Warning`) -- Anchor target → page exists but anchor doesn't → `Warning` -- `file:` / `local:` → file exists on disk → `Warning` if missing -- Raw URL / external → never diagnosed (we don't fetch) -- Configurable filtering via `diagnostic.link.severity` (`off|hint|warn|error`) - -#### Commands - -| Command | Behaviour | -|---|---| -| `nuwiki.toc.generate` | inserts a nested list of headings in the current page; replaces existing `%toc` placeholder if present | -| `nuwiki.links.generate` | inserts a flat list of all wiki pages (vimwiki's `:VimwikiGenerateLinks`) | -| `nuwiki.workspace.checkLinks` | returns a `Vec` for the client to render in a quickfix-style view; also emits diagnostics | -| `nuwiki.workspace.findOrphans` | returns pages with no incoming links | - -### 12.7 Diary (Phase 16) - -> **Date parsing.** Diary entries use `YYYY-MM-DD` (and `YYYY-Www`, -> `YYYY-MM`, `YYYY` for non-daily frequencies). v1.1 ships a small -> hand-rolled parser/formatter in `nuwiki-core` for these formats — -> no `chrono` / `time` dependency. The parsable surface is small enough -> that the dependency cost outweighs the convenience. - -#### Path conventions - -``` -// # default: "diary" - diary.wiki # configurable: diary_index - 2026-05-11.wiki # daily: YYYY-MM-DD - 2026-W19.wiki # weekly: YYYY-Www (configurable) - 2026-05.wiki # monthly - 2026.wiki # yearly -``` - -#### Custom commands - -| Command | Behaviour | -|---|---| -| `nuwiki.diary.openToday` | open / create today's diary page | -| `nuwiki.diary.openYesterday` | analogous | -| `nuwiki.diary.openTomorrow` | analogous | -| `nuwiki.diary.openIndex` | open the diary index page | -| `nuwiki.diary.generateIndex` | rebuild the diary index page with current entries grouped by year/month | -| `nuwiki.diary.next` | navigate to the chronologically next diary entry from the current one | -| `nuwiki.diary.prev` | analogous | - -#### Calendar hook - -External plugins (Calendar.vim, neorg-style calendars) can query: - -- `nuwiki.diary.listEntries(year, month)` → list of dates with entries -- `nuwiki.diary.openForDate(date)` → opens that date's entry - -These are `executeCommand` operations, not LSP standard methods. - -### 12.8 HTML export commands (Phase 17) - -The `HtmlRenderer` from Phase 5 stays as the engine. v1.1 wraps it in -user-facing commands and the per-wiki HTML lifecycle. - -#### Per-wiki HTML options - -| Key | Default | Description | -|---|---|---| -| `html_path` | `/_html` | output directory | -| `template_path` | `/_templates` | template lookup root | -| `template_default` | `default` | base template name | -| `template_ext` | `.tpl` | template extension | -| `template_date_format` | `%Y-%m-%d` | format passed to `{{date}}` | -| `css_name` | `style.css` | CSS file copied/created at export | -| `auto_export` | `false` | export on save | -| `html_filename_parameterization` | `false` | URL-safe slugs in output filenames | -| `exclude_files` | `[]` | globs to skip | - -#### Commands - -| Command | Behaviour | -|---|---| -| `nuwiki.export.currentToHtml` | render current page; honours `%template`, copies CSS if missing | -| `nuwiki.export.allToHtml` | export every page in the wiki | -| `nuwiki.export.allToHtmlForce` | `:VimwikiAll2HTML!` equivalent | -| `nuwiki.export.browse` | export current + open default browser to result | -| `nuwiki.export.rss` | emit `rss.xml` of recent diary entries (when diary enabled) | - -#### Template variables - -The `HtmlRenderer` template substitution gains: - -- `{{title}}` (already shipped) -- `{{content}}` (already shipped) -- `{{date}}` (formatted via `template_date_format`) -- `{{root_path}}` (relative path from output to `html_path` — for stylesheet - hrefs in nested pages) -- `{{toc}}` (rendered TOC for current page) - -### 12.9 Multi-wiki (Phase 18) - -> **Data-structure pre-work.** The `Wiki` aggregate, `WorkspaceIndex` -> per-wiki ownership, and `Backend::resolve_uri_to_wiki` already landed -> in Phase 11 with a single-entry `wikis` list. Phase 18 is therefore -> a *config-shape* change (accept `wikis = [...]` from -> `initializationOptions`) and *resolution-policy* change (interwiki -> links resolve through the matching wiki's index) — not a refactor of -> the data flow. - -#### Config shape - -```lua -require('nuwiki').setup({ - wikis = { - { - name = 'personal', - root = '~/vimwiki', - file_extension = '.wiki', - syntax = 'vimwiki', - diary_rel_path = 'diary', - html_path = '~/vimwiki/_html', - -- … any per-wiki option - }, - { - name = 'work', - root = '~/work-notes', - syntax = 'vimwiki', - }, - }, - log_level = 'warn', -}) -``` - -The v1.0 single-wiki shape continues to work; internally it desugars to -`wikis = [{ root = wiki_root, file_extension = file_extension, syntax = syntax }]`. - -#### Per-wiki context - -`WorkspaceIndex` becomes one per wiki. Backend holds: - -``` -indexes: Arc>>> -``` - -URI → wiki resolution walks the URI's filesystem path upward, matching -against each registered root. Cross-wiki links (interwiki) resolve through -the matching wiki's index. - -#### Custom commands - -| Command | Behaviour | -|---|---| -| `nuwiki.wiki.select` | client-side picker over `wikis`; resolves to a wiki id | -| `nuwiki.wiki.openIndex` | open the selected wiki's `index.wiki` | -| `nuwiki.wiki.tabOpenIndex` | open in new tab (client-side) | -| `nuwiki.wiki.listAll` | returns the registered wikis for editor UIs | - -### 12.10 Editor glue v2 (Phase 19) - -The thickest user-facing layer. Per SPEC §6.3 it still contains no logic — -every command body is a wrapper that issues `workspace/executeCommand`. - -#### `:Vimwiki*` command compatibility - -`ftplugin/nuwiki.vim` defines every command listed in §4 of vimwiki's help, -mapped to the corresponding `executeCommand`: - -| Vimwiki command | nuwiki implementation | -|---|---| -| `:VimwikiIndex` `[count]` | `nuwiki.wiki.openIndex(count)` | -| `:VimwikiTabIndex` | tab-open variant | -| `:VimwikiUISelect` | `nuwiki.wiki.select` | -| `:VimwikiDiaryIndex` | `nuwiki.diary.openIndex` | -| `:VimwikiMakeDiaryNote` | `nuwiki.diary.openToday` | -| `:VimwikiMakeYesterdayDiaryNote` | `nuwiki.diary.openYesterday` | -| `:VimwikiMakeTomorrowDiaryNote` | `nuwiki.diary.openTomorrow` | -| `:VimwikiFollowLink` | client `vim.lsp.buf.definition()` | -| `:VimwikiGoBackLink` | client `` (jumplist) | -| `:VimwikiSplitLink` / `:VimwikiVSplitLink` | client-side `:split`/`:vsplit` + `definition` | -| `:VimwikiTabnewLink` / `:VimwikiTabDropLink` | client-side `:tabnew` + `definition` | -| `:VimwikiNextLink` / `:VimwikiPrevLink` | `nuwiki.cursor.nextLink` / `prevLink` | -| `:VimwikiGoto {name}` | `nuwiki.wiki.gotoPage(name)` | -| `:VimwikiDeleteFile` | server-side delete via `executeCommand` | -| `:VimwikiRenameFile` | client prompt → `workspace/rename` | -| `:VimwikiRemoveDone` | `nuwiki.list.removeDone` | -| `:VimwikiNextTask` | `nuwiki.list.nextTask` | -| `:Vimwiki2HTML` | `nuwiki.export.currentToHtml` | -| `:Vimwiki2HTMLBrowse` | `nuwiki.export.browse` | -| `:VimwikiAll2HTML[!]` | `nuwiki.export.allToHtml[Force]` | -| `:VimwikiRss` | `nuwiki.export.rss` | -| `:VimwikiToggleListItem` | `nuwiki.list.toggleCheckbox` | -| `:VimwikiToggleRejectedListItem` | `nuwiki.list.rejectCheckbox` | -| `:VimwikiListChangeLvl CMD` | `nuwiki.list.changeLevel` / `changeSymbol` | -| `:VimwikiSearch` / `:VWS` | client `:lvimgrep` wrapper over wiki root | -| `:VimwikiBacklinks` / `:VWB` | client `vim.lsp.buf.references()` | -| `:VimwikiTable [cols] [rows]` | `nuwiki.table.insert` | -| `:VimwikiTableMoveColumnLeft/Right` | `nuwiki.table.moveColumn` | -| `:VimwikiGenerateLinks` | `nuwiki.links.generate` | -| `:VimwikiDiaryGenerateLinks` | `nuwiki.diary.generateIndex` | -| `:VimwikiDiaryNextDay` / `:VimwikiDiaryPrevDay` | `nuwiki.diary.next/prev` | -| `:VimwikiTOC` | `nuwiki.toc.generate` | -| `:VimwikiCheckLinks` | `nuwiki.workspace.checkLinks` | -| `:VimwikiRebuildTags` | `nuwiki.tags.rebuild` | -| `:VimwikiSearchTags {tag}` | `nuwiki.tags.search` | -| `:VimwikiGenerateTagLinks` | `nuwiki.tags.generateLinks` | -| `:VimwikiColorize {color}` | `nuwiki.colorize` | -| `:VimwikiPasteLink` | `nuwiki.link.pasteWikilink` | -| `:VimwikiPasteUrl` | `nuwiki.link.pasteUrl` | - -Each `:Vimwiki*` form ships with a matching `:Nuwiki*` alias (P10 -resolution). - -#### Default keymaps - -`g:nuwiki_key_mappings` (Vim) / `mappings = { … }` (Neovim) controls -opt-in keymap registration. Default: enabled; group flags let users disable -the table/list/heading/diary subsets independently. - -Buffer-local maps mirror vimwiki: - -- `ww` / `wt` / `ws` / `wi` / - `ww` etc. — wiki navigation -- `` / `` / `` / `` / `` — link follow variants -- `` / `` / `` — link history + per-page link navigation -- `=` / `-` / `[[` / `]]` / `[=` / `]=` / `]u` / `[u` — header manipulation + - navigation (pure VimL/Lua, regex over current buffer — no LSP round-trip) -- `+` — `nuwiki.link.normalize` -- `` — `nuwiki.list.toggleCheckbox` -- `gnt` — `nuwiki.list.nextTask` -- `gl` / `gL` — checkbox remove (single / list) -- `gln` / `glp` — checkbox cycle up/down -- `gll` / `gLl` / `glh` / `gLh` — list level change (single / subtree) -- `glr` / `gLr` — list renumber -- `gl*` / `gl#` / `gl-` / `gl+` / `gl1` / `gla` / `glA` / `gli` / `glI` — - list symbol change (single / subtree variants `gL`*) -- `glx` — `:VimwikiToggleRejectedListItem` -- `gqq` / `gww` / `gq1` / `gw1` — table align -- `` / `` — table move column -- `` / `` — diary prev/next day -- `wh` / `whh` — HTML export current / browse -- `wc` — colorize (asks for colour) -- `wn` — goto/create new page -- `wd` / `wr` — delete / rename current page -- Mouse: `<2-LeftMouse>`, ``, ``, - `` (opt-in via `g:nuwiki_mouse_mappings`) - -#### Insert-mode behaviour - -- Table cells: ``/``/`` navigate cells, create rows - on overflow; implemented in Lua/VimL via a small autocmd -- Lists: `` inserts the next bullet/number; `` continues without - a new bullet; ``/`` indent/dedent; ``/`` - cycle the list symbol; `` toggles a list item on/off -- All gated by `b:did_ftplugin` and remappable - -#### Text objects - -`omap`/`xmap` defining: - -| Object | Selects | -|---|---| -| `ah` / `ih` | a header (with/without trailing blank lines + header line itself) | -| `aH` / `iH` | a header + all sub-headers ([count] climbs parent levels) | -| `a\` / `i\` | a table cell | -| `ac` / `ic` | a table column | -| `al` / `il` | a list item (with/without children) | - -Pure VimL/Lua — no LSP round-trip. Reuses our AST-shaped regexes. - -#### Folding - -Per P14 resolution; the spec'd default (subject to that decision): - -- `setlocal foldmethod=expr` + `foldexpr=NuwikiFold(v:lnum)` (or LSP - `foldingRange` driving it on Neovim 0.11+) -- Folds at heading boundaries; nested headings nest folds -- List-item folds when configured via `g:nuwiki_folding = 'list'` -- Custom variant via `g:nuwiki_folding = 'custom'` - -#### Health check additions - -`:checkhealth nuwiki` v1.1 reports: - -- LSP commands registered (per command name) -- Index loaded for each registered wiki -- Tags table built -- HTML output path writable -- Default keymaps installed (or disabled by config) - -### 12.11 Updated user-config schema - -```lua -require('nuwiki').setup({ - -- single-wiki shorthand (v1.0 compat) - wiki_root = '~/vimwiki', - file_extension = '.wiki', - syntax = 'vimwiki', - log_level = 'warn', - - -- or the v1.1 multi-wiki form - wikis = { - { - name = 'personal', - root = '~/vimwiki', - file_extension = '.wiki', - syntax = 'vimwiki', - diary_rel_path = 'diary', - diary_frequency = 'daily', -- daily | weekly | monthly | yearly - diary_start_week_day = 'monday', - template_path = '~/vimwiki/_templates', - template_default = 'default', - template_ext = '.tpl', - css_name = 'style.css', - html_path = '~/vimwiki/_html', - auto_export = false, - auto_toc = false, - auto_tags = false, - exclude_files = {}, - bullet_types = { '-', '*', '#' }, - listsyms = ' .oOX', - listsym_rejected = '-', - listsyms_propagate = true, - color_dic = { red = 'red', green = 'green' }, - }, - }, - - -- workspace-wide behaviour - diagnostic = { - link_severity = 'warn', -- off | hint | warn | error - }, - mappings = { - enabled = true, - table_editing = true, - list_editing = true, - header_nav = true, - diary = true, - html_export = true, - mouse = false, - }, - folding = 'syntax', -- off | syntax | list | custom -}) -``` - -### 12.12 Updated §9 syntax checklist (additions) - -The following items extend the v1.0 checklist: - -#### Tags -- [ ] Inline tag sequence: `:tag-one:tag-two:` -- [ ] File-level tag (line 1–2 of a wiki page) -- [ ] Header-level tag (within 2 lines after a heading) -- [ ] Tag-as-anchor target (`[[Page#some-tag]]`) -- [ ] Hex-colour code rendering in code blocks (`` `#ffe119` ``) - -#### Tables (additions) -- [ ] Column-alignment markers in header separator: `|:--|` / `|:--:|` / `|--:|` - -#### Lists (parser extensions) -- [ ] Multi-line list item (indented continuation lines) -- [ ] Mixed-symbol lists at the same level - -### 12.13 Updated CI/CD - -No new workflow files. The existing `ci.yaml` continues to gate -`fmt`/`clippy`/`test`. New phases land behind feature additions in the -existing crates; the test suite grows accordingly. - ---- - -## 13. Pending Implementation - -Items specified in earlier phases but not yet implemented. Tracked here -so a future audit doesn't have to re-derive the scope from the per-phase -prose. Each entry includes the originating SPEC section, why it was -deferred, and a sketch of what the implementation needs. - -### 13.1 Phase 14 — list/table/link/colorize commands ✅ - -All eleven commands deferred from Phase 14 are now shipped: - -| Command | Status | Notes | -|---|---|---| -| `nuwiki.list.changeSymbol` | ✅ done | `ops::change_symbol_edit` + `parse_symbol` + `render_marker` (incl. alpha + roman variants). | -| `nuwiki.list.changeLevel` | ✅ done | ±2-space indent per delta; `whole_subtree` walks descendants; dedent clamps at column 0. | -| `nuwiki.list.renumber` | ✅ done | Re-sequences numeric markers in the current list, or every list when `whole_file = true`. | -| `nuwiki.list.removeDone` | ✅ done | Strips `[X]` / `[-]` items + their sublists; optional `position` scopes to one item. | -| `nuwiki.table.align` | ✅ done | Width-per-column pass + row re-emit. Re-inserts the dropped `|---|---|` separator after the header. | -| `nuwiki.table.moveColumn` | ✅ done | Swap column with neighbour (`dir: "left" \| "right"`); widths swap alongside. | -| `nuwiki.table.insert` | ✅ done | Pure templating — `cols × rows` blank cells + header separator. | -| `nuwiki.link.normalize` | ✅ done | Client-side word-wrap (Lua/VimL). Mirrors the `` two-step wrap step. | -| `nuwiki.link.pasteWikilink` | ✅ done | Server-side; inserts `[[]]` at cursor. | -| `nuwiki.link.pasteUrl` | ✅ done | Server-side; inserts `.html` (subdir-aware). | -| `nuwiki.colorize` | ✅ done | Client-side wrap in `` (matches vimwiki default template). | - -`color_dic` integration with the `ColorNode` HTML renderer also -landed alongside `colorize` — the dict maps vimwiki colour-tag names -to CSS values; missing names fall back to `class="color-"`. - -### 13.2 Phase 13 — `will_rename` / `will_delete` capability - -The Phase 17 backfill (commit `ad1b55a`) advertised `did_rename` and -`did_delete` but intentionally left `will_rename` / `will_delete` off. -Those variants return a `WorkspaceEdit` synchronously from the server — -useful for, e.g., rewriting all incoming `[[Page]]` links *before* a -client-initiated file rename commits to disk. Implementation would -share most of the cross-doc rewrite logic from -`rename::compute_rename` (Phase 13) but at a different LSP entry point. -Deferred because the value proposition over the existing -`workspace/rename` flow is marginal and the editor-side support is -inconsistent across clients. - -### 13.3 v1.1 status - -All numbered phases through 19 are shipped. The remaining work is -the §13.1 deferred Phase 14 command surface (table/list rewriters, -link helpers, colorize) and the small follow-ups noted under §13.2 / -§13.4. - -Earlier v1.1 phases now shipped: - -- Phase 17 (HTML export commands) — `currentToHtml`, - `allToHtml[Force]`, `browse`, `rss`, template-file + fallback - resolution, `{{date}}` / `{{root_path}}` / `{{toc}}` / `{{css}}` - template variables, and `did_save` auto-export when - `auto_export = true`. `color_dic` integration with the `ColorNode` - renderer is still open (only useful once §13.1 `colorize` lands), - as is the §13.1 `link.pasteUrl` follow-up that depends on the same - export config. -- Phase 18 (multi-wiki) — `wikis = [...]` config shape was already - accepted by Phase 11 plumbing; this phase added the cross-wiki - interwiki resolver (`Backend::resolve_target_uri` / - `heading_range_for` route `[[wiki:Page]]` and - `[[wn.:Page]]` to the destination wiki's index) and four - picker commands: `nuwiki.wiki.listAll`, `nuwiki.wiki.select`, - `nuwiki.wiki.openIndex`, `nuwiki.wiki.tabOpenIndex`, plus - `nuwiki.wiki.gotoPage` for the `:VimwikiGoto` compat surface. -- Phase 19 (editor glue v2) — LSP `textDocument/foldingRange` - provider (`folding::folding_ranges`) with heading-block + top-level - list semantics; advertised via `folding_range_provider`. A pure - Lua `foldexpr` fallback ships in `lua/nuwiki/folding.lua` for - clients without `foldingRange` support. `ftplugin/nuwiki.vim` - defines the full `:Vimwiki*` compat surface plus `:Nuwiki*` - aliases — every command that maps to a server-side `executeCommand` - is wired; §13.1-deferred commands stub out with a "not yet - implemented" notification so users get a clear signal. Default - buffer-local keymaps cover the working subset (checkbox toggle, - next task, heading promote/demote via `g=`/`g-`, diary navigation - via ``/``, HTML export under `wh*`). A - heading text object (`ah`/`ih`) ships now; `aH`/`iH`/`a\`/`i\`/ - `ac`/`ic`/`al`/`il` land once the §13.1 table/list rewriters - arrive. - -### 13.4 §9 syntax checklist status - -The §9 checklist tracks every vimwiki-syntax surface. Items currently -marked `[ ]` there are parser-level coverage assertions, not missing -commands — they're driven by the existing test suite, not by Phase -14+ command work. Don't confuse "syntax checklist unchecked" with -"feature missing" — most of those rows are exercised by the parser -tests under `crates/nuwiki-core/tests/` and just haven't had their -checkboxes ticked. - ---- - diff --git a/autoload/nuwiki/commands.vim b/autoload/nuwiki/commands.vim index 2e7f976..7908023 100644 --- a/autoload/nuwiki/commands.vim +++ b/autoload/nuwiki/commands.vim @@ -1,5 +1,5 @@ " autoload/nuwiki/commands.vim — Vim-side wrappers around the LSP -" commands the server advertises (Phase 19). +" commands the server advertises. " " Lives in autoload/ so the cost is paid only when a `:Vimwiki*` / " `:Nuwiki*` command is actually invoked. Dispatches via vim-lsp's @@ -107,7 +107,7 @@ endfunction function! s:notify_deferred(name) abort echohl WarningMsg - echom 'nuwiki: ' . a:name . ' is not yet implemented — see SPEC §13.1' + echom 'nuwiki: ' . a:name . ' is not yet implemented' echohl None endfunction @@ -478,6 +478,13 @@ endfunction function! nuwiki#commands#cycle_list_item() abort call s:exec_pos('nuwiki.list.cycleCheckbox') endfunction +" `glp` cycles backward — same command, `reverse` flag in the payload. +function! nuwiki#commands#cycle_list_item_back() abort + let l:p = s:cursor_position() + let l:args = [{ 'uri': l:p['textDocument']['uri'], + \ 'position': l:p['position'], 'reverse': v:true }] + call s:exec('nuwiki.list.cycleCheckbox', l:args) +endfunction function! nuwiki#commands#reject_list_item() abort call s:exec_pos('nuwiki.list.rejectCheckbox') endfunction @@ -627,11 +634,20 @@ function! nuwiki#commands#rename_file() abort endif endfunction -" ===== §13.1 deferred ===== +" ===== Deferred ===== -" §13.1 Cluster A — list rewriters. +" Cluster A — list rewriters. +" `gl` — remove done items from the current list only. Passing the +" cursor position scopes the server to the contiguous list block under it. function! nuwiki#commands#list_remove_done() abort + let l:p = s:cursor_position() + let l:args = [{ 'uri': l:p['textDocument']['uri'], 'position': l:p['position'] }] + call s:exec('nuwiki.list.removeDone', l:args) +endfunction + +" `gL` — remove done items across the whole buffer (no position). +function! nuwiki#commands#list_remove_done_all() abort let l:args = [{ 'uri': s:buf_uri() }] call s:exec('nuwiki.list.removeDone', l:args) endfunction @@ -721,7 +737,7 @@ function! nuwiki#commands#list_toggle_or_add_checkbox() abort let l:new = strpart(l:line, 0, l:end) . '[ ] ' . strpart(l:line, l:end) call setline('.', l:new) endfunction -" §13.1 Cluster B — table rewriters. +" Cluster B — table rewriters. function! nuwiki#commands#table_insert(...) abort let l:cols = a:0 >= 1 ? str2nr(a:1) : 3 @@ -799,7 +815,7 @@ function! nuwiki#commands#colorize(...) abort \ . strpart(l:line, l:right - 1) call setline('.', l:new) endfunction -" §13.1 Cluster C — link helpers. +" Cluster C — link helpers. function! nuwiki#commands#paste_link() abort let l:p = s:cursor_position() diff --git a/autoload/nuwiki/lsp.vim b/autoload/nuwiki/lsp.vim index 1238ea0..34759c0 100644 --- a/autoload/nuwiki/lsp.vim +++ b/autoload/nuwiki/lsp.vim @@ -1,6 +1,6 @@ " autoload/nuwiki/lsp.vim — Vim (non-Neovim) LSP client wiring. " -" Spec §7.4 preference order: +" Preference order: " 1. vim-lsp (matoto/vim-lsp) " 2. coc.nvim " 3. Print an error if neither is available. diff --git a/crates/nuwiki-core/src/ast/block.rs b/crates/nuwiki-core/src/ast/block.rs index 2a12d09..52ccf8d 100644 --- a/crates/nuwiki-core/src/ast/block.rs +++ b/crates/nuwiki-core/src/ast/block.rs @@ -1,6 +1,4 @@ //! Block-level AST nodes and their supporting types. -//! -//! See SPEC.md §6.4. use super::inline::InlineNode; use super::span::Span; @@ -17,7 +15,7 @@ pub enum BlockNode { DefinitionList(DefinitionListNode), Table(TableNode), Comment(CommentNode), - /// Vimwiki tag line — `:tag1:tag2:`. Phase 12 (v1.1). The placement + /// 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), diff --git a/crates/nuwiki-core/src/ast/inline.rs b/crates/nuwiki-core/src/ast/inline.rs index 59c48b5..47f878a 100644 --- a/crates/nuwiki-core/src/ast/inline.rs +++ b/crates/nuwiki-core/src/ast/inline.rs @@ -1,6 +1,6 @@ //! Inline AST nodes. //! -//! See SPEC.md §6.4. The link-related variants (`WikiLink`, `ExternalLink`, +//! The link-related variants (`WikiLink`, `ExternalLink`, //! `Transclusion`, `RawUrl`) wrap structs defined in `super::link`. use super::link::{ExternalLinkNode, RawUrlNode, TransclusionNode, WikiLinkNode}; diff --git a/crates/nuwiki-core/src/ast/link.rs b/crates/nuwiki-core/src/ast/link.rs index 8f6a74c..e3b230c 100644 --- a/crates/nuwiki-core/src/ast/link.rs +++ b/crates/nuwiki-core/src/ast/link.rs @@ -1,6 +1,6 @@ //! Link-related AST nodes and supporting types. //! -//! See SPEC.md §6.4. All nodes here are inline (`InlineNode` variants); +//! 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. diff --git a/crates/nuwiki-core/src/ast/mod.rs b/crates/nuwiki-core/src/ast/mod.rs index 35ca6c4..ca868f2 100644 --- a/crates/nuwiki-core/src/ast/mod.rs +++ b/crates/nuwiki-core/src/ast/mod.rs @@ -1,7 +1,7 @@ //! AST types for nuwiki documents. //! //! Types here are syntax-agnostic — both vimwiki and (eventually) markdown -//! produce the same node shapes. See SPEC.md §6.4. +//! produce the same node shapes. pub mod block; pub mod inline; @@ -28,7 +28,7 @@ pub use visit::{ walk_table, walk_table_row, Visitor, }; -/// Root of an AST. See SPEC.md §6.4. +/// Root of an AST. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct DocumentNode { pub span: Span, @@ -37,8 +37,7 @@ pub struct DocumentNode { } /// Document-level placeholders parsed from `%title` / `%nohtml` / `%template` -/// / `%date` directives, plus the v1.1 file-level tag accumulator -/// (SPEC §12.3). See SPEC.md §9. +/// / `%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. @@ -48,7 +47,7 @@ pub struct PageMetadata { pub nohtml: bool, pub template: Option, pub date: Option, - /// File-scope tags parsed by the v1.1 tag lexer (Phase 12). Convenience + /// 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 index 3d6a5f8..aefc11c 100644 --- a/crates/nuwiki-core/src/ast/span.rs +++ b/crates/nuwiki-core/src/ast/span.rs @@ -1,6 +1,6 @@ //! Source positions and spans carried on every AST node. //! -//! See SPEC.md §6.5. Positions are 0-indexed; `column` is a byte offset +//! 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)] diff --git a/crates/nuwiki-core/src/ast/visit.rs b/crates/nuwiki-core/src/ast/visit.rs index 23312ce..30d5783 100644 --- a/crates/nuwiki-core/src/ast/visit.rs +++ b/crates/nuwiki-core/src/ast/visit.rs @@ -5,10 +5,10 @@ //! 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. See SPEC.md §6.4 + P4. +//! Pattern follows `rustc`'s and `syn`'s visitors. //! //! Read-only for now. A `VisitorMut` flavor can be added when transformations -//! become relevant (post-Phase 5). +//! become relevant. use super::block::{ BlockNode, BlockquoteNode, CommentNode, DefinitionItemNode, DefinitionListNode, ErrorNode, diff --git a/crates/nuwiki-core/src/date.rs b/crates/nuwiki-core/src/date.rs index 5f91bb4..8687fc3 100644 --- a/crates/nuwiki-core/src/date.rs +++ b/crates/nuwiki-core/src/date.rs @@ -1,6 +1,6 @@ -//! Date primitives for the diary subsystem (Phase 16). +//! Date primitives for the diary subsystem. //! -//! v1.1 deliberately doesn't pull in `chrono` or `time`. The diary feature +//! 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. //! @@ -56,7 +56,7 @@ impl DiaryDate { format!("{:04}-{:02}-{:02}", self.year, self.month, self.day) } - /// UTC "today" computed from `SystemTime::now()`. Phase 16's diary + /// 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. diff --git a/crates/nuwiki-core/src/lib.rs b/crates/nuwiki-core/src/lib.rs index 8401d3a..0c57b44 100644 --- a/crates/nuwiki-core/src/lib.rs +++ b/crates/nuwiki-core/src/lib.rs @@ -1,7 +1,7 @@ //! Core parser, AST, and renderer for nuwiki. //! //! This crate is editor-independent and must never depend on `nuwiki-lsp` or -//! `nuwiki-ls`. See SPEC.md §6.2 for the dependency rules. +//! `nuwiki-ls`. pub mod ast; pub mod date; diff --git a/crates/nuwiki-core/src/render/html.rs b/crates/nuwiki-core/src/render/html.rs index 59cea3a..1d3e792 100644 --- a/crates/nuwiki-core/src/render/html.rs +++ b/crates/nuwiki-core/src/render/html.rs @@ -3,12 +3,12 @@ //! 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 (SPEC.md §6.8 + §12.8). +//! 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 (Phase 17 ships -//! `{{date}}`, `{{root_path}}`, `{{toc}}`). Order: `{{content}}` is +//! `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. //! @@ -44,7 +44,7 @@ pub struct HtmlRenderer { /// `color_dic`-style override: vimwiki colour-tag names → CSS /// values (`"red"` / `"#ffe119"` / `"oklch(…)"`). Unspecified /// names fall through to the default `class="color-"` - /// rendering. Matches SPEC §12.11 `color_dic`. + /// rendering. Matches vimwiki's `color_dic`. colors: HashMap, } @@ -662,7 +662,7 @@ fn table_spans(table: &TableNode) -> Vec> { // ===== Default link resolver ===== -/// Default `LinkResolver`. Mirrors the conventions in SPEC.md §9: +/// 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. diff --git a/crates/nuwiki-core/src/render/mod.rs b/crates/nuwiki-core/src/render/mod.rs index a305703..d22520a 100644 --- a/crates/nuwiki-core/src/render/mod.rs +++ b/crates/nuwiki-core/src/render/mod.rs @@ -1,6 +1,6 @@ //! Document renderers. //! -//! SPEC.md §6.8: a `Renderer` is writer-based, takes a `DocumentNode`, and +//! 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). diff --git a/crates/nuwiki-core/src/syntax/mod.rs b/crates/nuwiki-core/src/syntax/mod.rs index 4dffa33..39fc746 100644 --- a/crates/nuwiki-core/src/syntax/mod.rs +++ b/crates/nuwiki-core/src/syntax/mod.rs @@ -4,8 +4,6 @@ //! 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`. -//! -//! See SPEC.md §6.3 (interface) and §6.6 (lexer strategy). pub mod registry; pub mod vimwiki; @@ -15,7 +13,7 @@ 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 (SPEC.md §6.6) without breaking +/// open to a lazy/streaming variant later without breaking /// callers. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct TokenStream { @@ -89,7 +87,7 @@ pub trait Lexer { } /// Parser half of a syntax plugin. Consumes a `TokenStream` and produces a -/// `DocumentNode`. Per SPEC.md §6.7 parsing is resilient — malformed input +/// `DocumentNode`. Parsing is resilient — malformed input /// becomes `BlockNode::Error(ErrorNode)`, never a hard failure. pub trait Parser { type Token; @@ -104,13 +102,13 @@ pub trait Parser { /// 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 (SPEC.md §6.3). +/// 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"]`. See SPEC.md §6.3. + /// 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 index 84de3d7..b12fa64 100644 --- a/crates/nuwiki-core/src/syntax/registry.rs +++ b/crates/nuwiki-core/src/syntax/registry.rs @@ -1,4 +1,4 @@ -//! Registry of syntax plugins. See SPEC.md §6.3. +//! 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. diff --git a/crates/nuwiki-core/src/syntax/vimwiki/lexer.rs b/crates/nuwiki-core/src/syntax/vimwiki/lexer.rs index f8f13da..21948bb 100644 --- a/crates/nuwiki-core/src/syntax/vimwiki/lexer.rs +++ b/crates/nuwiki-core/src/syntax/vimwiki/lexer.rs @@ -1,6 +1,6 @@ //! Vimwiki lexer. //! -//! Hand-rolled, two-pass per SPEC.md §6.6: +//! Hand-rolled, two-pass: //! //! - **Block pass:** scan the source line by line, recognise structural //! constructs (headings, lists, tables, fences, comments, etc.), emit @@ -12,7 +12,7 @@ //! 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 per SPEC.md §6.5. +//! stored as 0-indexed byte offsets. //! //! Multi-line constructs (`{{{ }}}`, `{{$ }}$`, `%%+ +%%`) flip a //! `BlockMode` so subsequent lines are treated as raw content until the @@ -97,13 +97,13 @@ pub enum VimwikiTokenKind { CommentMultiClose, CommentMultiLine(String), - // ----- Tags (SPEC §12.3) ----- + // ----- 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 (SPEC §9) ----- + // ----- Page placeholders ----- PlaceholderTitle(Option), PlaceholderNohtml, PlaceholderTemplate(Option), @@ -129,7 +129,7 @@ pub enum VimwikiTokenKind { TransclusionSep, RawUrl(String), - /// Lex error — never aborts the whole document (SPEC §6.7). + /// Lex error — never aborts the whole document. Error(String), } @@ -481,7 +481,7 @@ impl<'src> LexState<'src> { if trimmed.len() < 4 || !trimmed.bytes().all(|b| b == b'-') { return false; } - // SPEC §9: four or more dashes. Allow surrounding whitespace. + // 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); diff --git a/crates/nuwiki-core/src/syntax/vimwiki/mod.rs b/crates/nuwiki-core/src/syntax/vimwiki/mod.rs index 0667564..6b2d6d0 100644 --- a/crates/nuwiki-core/src/syntax/vimwiki/mod.rs +++ b/crates/nuwiki-core/src/syntax/vimwiki/mod.rs @@ -10,7 +10,7 @@ use crate::ast::DocumentNode; use crate::syntax::{Lexer, Parser, SyntaxPlugin}; /// SyntaxPlugin facade: chains `VimwikiLexer` + `VimwikiParser` so the -/// registry can hand out a single trait object per SPEC.md §6.3. +/// registry can hand out a single trait object. #[derive(Debug, Default, Clone)] pub struct VimwikiSyntax; diff --git a/crates/nuwiki-core/src/syntax/vimwiki/parser.rs b/crates/nuwiki-core/src/syntax/vimwiki/parser.rs index 95179d7..d0153fa 100644 --- a/crates/nuwiki-core/src/syntax/vimwiki/parser.rs +++ b/crates/nuwiki-core/src/syntax/vimwiki/parser.rs @@ -1,6 +1,6 @@ //! Vimwiki parser. //! -//! Hand-rolled recursive-descent over `Vec` (SPEC.md §6.7). +//! 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. @@ -28,7 +28,7 @@ //! //! `[[ ]]` payloads are inspected to choose between `WikiLinkNode` //! (the default) and `ExternalLinkNode` (when the target is a URL). The -//! `LinkTarget` enum follows SPEC §9: leading `/` is root-relative, +//! `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. @@ -69,10 +69,10 @@ impl Parser for VimwikiParser { struct ParseState<'a> { toks: &'a [VimwikiToken], pos: usize, - /// Phase 12: count of `HeadingNode`s already pushed to `children`. + /// Count of `HeadingNode`s already pushed to `children`. /// `headings_seen - 1` is the most recent heading's index. headings_seen: usize, - /// Phase 12: source line of the most recently-emitted heading. + /// Source line of the most recently-emitted heading. /// `tag_line - last_heading_line ∈ {1, 2}` is a header-scope tag. last_heading_line: Option, } @@ -141,7 +141,7 @@ impl<'a> ParseState<'a> { } let before = self.pos; let block = self.parse_block(); - // Phase 12: file-scope tags get accumulated into metadata so + // 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. @@ -280,7 +280,7 @@ impl<'a> ParseState<'a> { let inline = parse_inline_seq(&self.toks[inline_start..self.pos]); self.advance(); self.eat_newline(); - // Record for tag-scope resolution (Phase 12). + // Record for tag-scope resolution. self.headings_seen += 1; self.last_heading_line = Some(span_end.line); return BlockNode::Heading(HeadingNode { diff --git a/crates/nuwiki-core/tests/syntax.rs b/crates/nuwiki-core/tests/syntax.rs index ebdc93d..4f0ef14 100644 --- a/crates/nuwiki-core/tests/syntax.rs +++ b/crates/nuwiki-core/tests/syntax.rs @@ -136,7 +136,7 @@ fn registry_can_parse_through_trait_object() { } /// Compile-time check: SyntaxRegistry must be Send + Sync so the LSP server -/// can share it across handler tasks (SPEC.md §6.3). +/// can share it across handler tasks. #[test] fn registry_is_send_and_sync() { fn assert_send_sync() {} diff --git a/crates/nuwiki-core/tests/tags.rs b/crates/nuwiki-core/tests/tags.rs index b524fa1..c92c727 100644 --- a/crates/nuwiki-core/tests/tags.rs +++ b/crates/nuwiki-core/tests/tags.rs @@ -1,4 +1,4 @@ -//! Phase 12 tag tests: lexer + parser + renderer behaviour on +//! Tag tests: lexer + parser + renderer behaviour on //! `:tag:` lines, plus the scope-detection rules. use nuwiki_core::ast::{BlockNode, TagScope}; diff --git a/crates/nuwiki-lsp/src/commands.rs b/crates/nuwiki-lsp/src/commands.rs index ac08c30..3bc74eb 100644 --- a/crates/nuwiki-lsp/src/commands.rs +++ b/crates/nuwiki-lsp/src/commands.rs @@ -1,10 +1,10 @@ //! `workspace/executeCommand` dispatcher. //! -//! Phase 13 introduced the router + `nuwiki.file.delete`. Phase 14 added -//! the cheap surgical edits — checkbox toggle/cycle/reject, heading -//! promote/demote, "next task" navigation. Phase 15 layers on the -//! generation + workspace-query commands: `toc.generate`, -//! `links.generate`, `workspace.checkLinks`, `workspace.findOrphans`. +//! The router handles `nuwiki.file.delete`, the cheap surgical edits — +//! checkbox toggle/cycle/reject, heading promote/demote, "next task" +//! navigation — and the generation + workspace-query commands: +//! `toc.generate`, `links.generate`, `workspace.checkLinks`, +//! `workspace.findOrphans`. //! //! Every command resolves to either a `WorkspaceEdit` (the server then //! calls `apply_edit`) or a JSON `Value` (returned to the client) — see @@ -87,7 +87,17 @@ pub(crate) async fn execute( Ok(list_checkbox(backend, args, ops::toggle_state)?.map(CommandOutcome::Edit)) } "nuwiki.list.cycleCheckbox" => { - Ok(list_checkbox(backend, args, ops::cycle_state)?.map(CommandOutcome::Edit)) + let reverse = args + .first() + .and_then(|v| v.get("reverse")) + .and_then(Value::as_bool) + .unwrap_or(false); + let mutate = if reverse { + ops::cycle_state_back + } else { + ops::cycle_state + }; + Ok(list_checkbox(backend, args, mutate)?.map(CommandOutcome::Edit)) } "nuwiki.list.rejectCheckbox" => { Ok(list_checkbox(backend, args, ops::reject_state)?.map(CommandOutcome::Edit)) @@ -653,7 +663,7 @@ fn tags_rebuild(backend: &Backend, args: Vec) -> Result, St }))) } -// ===== Phase 18: multi-wiki picker commands ===== +// ===== multi-wiki picker commands ===== fn wiki_list_all(backend: &Backend) -> Result, String> { let wikis = backend.wikis_snapshot(); @@ -811,7 +821,7 @@ fn wiki_goto_page(backend: &Backend, args: Vec) -> Result, }))) } -// ===== §13.1 Cluster A: list rewriters ===== +// ===== Cluster A: list rewriters ===== fn list_remove_done(backend: &Backend, args: Vec) -> Result, String> { let p = parse_optional_uri_arg(args.clone())?; @@ -944,7 +954,7 @@ fn list_change_level(backend: &Backend, args: Vec) -> Result) -> Result, String> { #[derive(Deserialize)] @@ -1020,7 +1030,7 @@ fn table_move_column(backend: &Backend, args: Vec) -> Result Option<&'static str> { + match current { + "[ ]" => Some("[X]"), + "[X]" => Some("[O]"), + "[O]" => Some("[o]"), + "[o]" => Some("[.]"), + "[.]" => Some("[ ]"), + "[-]" => Some("[ ]"), + _ => None, + } + } + /// Toggle the `[-]` "rejected" state. pub fn reject_state(current: &str) -> Option<&'static str> { match current { @@ -1647,7 +1671,7 @@ pub mod ops { None } - // ===== Phase 15: TOC + Links generation, workspace queries ===== + // ===== TOC + Links generation, workspace queries ===== use serde::Serialize; @@ -1973,7 +1997,7 @@ pub mod ops { /// on the diagnostics module's internals. pub use crate::diagnostics::collect_wiki_links; - // ===== §13.1 Cluster A: list rewriters ===== + // ===== Cluster A: list rewriters ===== use crate::edits::text_edit_delete; use nuwiki_core::ast::ListSymbol; @@ -2095,7 +2119,9 @@ pub mod ops { /// Delete every checkbox item whose state is `Done` or `Rejected`, /// cascading into their sublists. When `pos` is `Some`, restrict to - /// the list containing `pos`'s line; otherwise sweep the whole doc. + /// the contiguous list block containing `pos`'s line (the "current + /// list" — including its nested sublists); otherwise sweep the whole + /// doc. pub fn remove_done_edit( text: &str, ast: &DocumentNode, @@ -2103,16 +2129,8 @@ pub mod ops { pos: Option, utf8: bool, ) -> Option { - let restrict_line = pos.map(|p| p.line); let mut victims: Vec = Vec::new(); - walk_list_items(&ast.children, &mut |item| { - let matches_scope = match restrict_line { - None => true, - Some(line) => (item.span.start.line..=item.span.end.line).contains(&line), - }; - if !matches_scope { - return; - } + let mut collect = |item: &ListItemNode| { if matches!( item.checkbox, Some(nuwiki_core::ast::CheckboxState::Done) @@ -2120,7 +2138,19 @@ pub mod ops { ) { victims.push(extend_to_line_end(text, item.span)); } - }); + }; + match pos { + None => walk_list_items(&ast.children, &mut collect), + Some(p) => { + // "Current list" = the top-level contiguous list block the + // cursor sits in. Walk just that block (and its sublists), + // leaving done items in sibling lists untouched. + let list = find_top_list_at_line(ast, p.line)?; + for item in &list.items { + walk_list_items_in_item(item, &mut collect); + } + } + } if victims.is_empty() { return None; } @@ -2131,6 +2161,40 @@ pub mod ops { Some(b.build()) } + /// Find the top-level (contiguous) list block whose span contains + /// `line`. Unlike [`find_list_at_line`], this does not descend into + /// sublists — it returns the outermost list so callers operate on the + /// whole list block the cursor belongs to. + fn find_top_list_at_line(ast: &DocumentNode, line: u32) -> Option<&ListNode> { + for block in &ast.children { + if let Some(list) = find_top_list_in_block(block, line) { + return Some(list); + } + } + None + } + + fn find_top_list_in_block(block: &BlockNode, line: u32) -> Option<&ListNode> { + match block { + BlockNode::List(list) => { + if (list.span.start.line..=list.span.end.line).contains(&line) { + Some(list) + } else { + None + } + } + BlockNode::Blockquote(BlockquoteNode { children, .. }) => { + for c in children { + if let Some(l) = find_top_list_in_block(c, line) { + return Some(l); + } + } + None + } + _ => None, + } + } + /// Extend a span to include the trailing newline (so deletion removes /// the empty line that would otherwise be left behind). fn extend_to_line_end(text: &str, span: Span) -> Span { @@ -2480,7 +2544,7 @@ pub mod ops { Some((span, marker_str, i)) } - // ===== §13.1 Cluster B: table rewriters ===== + // ===== Cluster B: table rewriters ===== use nuwiki_core::ast::{TableNode, TableRowNode}; @@ -2738,7 +2802,7 @@ pub mod ops { Some(out) } - // ===== Phase 16: diary helpers ===== + // ===== diary helpers ===== use crate::edits::op_create; @@ -2792,10 +2856,10 @@ pub mod ops { b.build() } - // ===== Tag ops (Phase 12 commands) ===== + // ===== Tag ops ===== // - // SPEC §12.3 calls for three tag-driven commands beyond the indexing - // that Phase 12 already shipped. `tag_hits` powers `nuwiki.tags.search`; + // Three tag-driven commands beyond the indexing itself. + // `tag_hits` powers `nuwiki.tags.search`; // `tag_links_edit` powers `nuwiki.tags.generateLinks`; the rebuild // command is a direct re-walk of the wiki root in the dispatcher. @@ -2984,7 +3048,7 @@ pub mod ops { } } -/// Disk-touching helpers for the Phase 17 `nuwiki.export.*` commands. +/// Disk-touching helpers for the `nuwiki.export.*` commands. /// Kept out of `ops` because everything here is side-effecting — pure /// rendering / templating lives in [`crate::export`]. pub mod export_ops { diff --git a/crates/nuwiki-lsp/src/config.rs b/crates/nuwiki-lsp/src/config.rs index 50c1119..2998111 100644 --- a/crates/nuwiki-lsp/src/config.rs +++ b/crates/nuwiki-lsp/src/config.rs @@ -1,14 +1,13 @@ //! Server-side runtime config. //! //! Populated from `InitializeParams.initialization_options` at boot and -//! refreshed via `workspace/didChangeConfiguration` (P18). v1.0 single-wiki -//! shapes are accepted and desugared into the v1.1 `wikis = [...]` form so -//! existing setups keep working (P16). +//! refreshed via `workspace/didChangeConfiguration`. Legacy single-wiki +//! shapes are accepted and desugared into the `wikis = [...]` form so +//! existing setups keep working. //! -//! Only the fields actually consumed in Phase 11 land here. Phases 13–19 -//! extend `WikiConfig` (diary path, html path, template options, …) and -//! the top-level `Config` (diagnostic severity, mappings opt-in, …) as -//! they need them. +//! `WikiConfig` (diary path, html path, template options, …) and the +//! top-level `Config` (diagnostic severity, mappings opt-in, …) carry the +//! fields actually consumed by the server. use std::path::{Path, PathBuf}; @@ -39,9 +38,8 @@ pub struct WikiConfig { /// The full path is `//.wiki`. pub diary_index: String, /// `daily`/`weekly`/`monthly`/`yearly`. Picks the date format the - /// diary commands use. v1.1 ships `daily`; the others fall through - /// to vimwiki-style names but the commands are no-ops until - /// Cluster 4 lands. + /// diary commands use. `daily` is supported; the others fall through + /// to vimwiki-style names but the commands are no-ops for them. pub diary_frequency: String, /// First day of the week (`monday`..`sunday`). Used by weekly /// diary entry naming. @@ -74,7 +72,7 @@ pub struct WikiConfig { pub nested_syntaxes: std::collections::HashMap, /// Rebuild the page's TOC on save when set. pub auto_toc: bool, - /// Phase 17 HTML export options. See SPEC §12.8. + /// HTML export options. pub html: HtmlConfig, } @@ -104,7 +102,7 @@ pub struct HtmlConfig { /// to skip during `allToHtml*`. pub exclude_files: Vec, /// `color_dic`: vimwiki colour-tag name → CSS value mapping used - /// by the HTML renderer (SPEC §12.11). Empty by default; the + /// by the HTML renderer. Empty by default; the /// renderer falls back to `class="color-"` when a name /// isn't in the dict. pub color_dic: std::collections::HashMap, @@ -130,7 +128,7 @@ impl Default for HtmlConfig { impl HtmlConfig { /// Synthesise default paths under `root` when the user hasn't /// specified explicit ones. `html_path` defaults to `/_html` - /// and `template_path` to `/_templates` per SPEC §12.8. + /// and `template_path` to `/_templates`. pub fn from_root(root: &Path) -> Self { Self { html_path: root.join("_html"), @@ -284,7 +282,7 @@ fn default_links_space_char() -> String { " ".to_string() } -/// Fill in the v1.1 per-wiki keys that none of the constructors care +/// Fill in the per-wiki keys that none of the constructors care /// about. Centralising the defaults here keeps `empty()`, `from_root()`, /// the legacy single-wiki path, and `From` in sync. fn wiki_defaults() -> WikiDefaults { @@ -329,9 +327,9 @@ impl Config { /// Build a `Config` from `InitializeParams`. /// /// Priority for the wikis list: - /// 1. `initialization_options.wikis = [...]` (v1.1 shape) + /// 1. `initialization_options.wikis = [...]` (multi-wiki shape) /// 2. `initialization_options.wiki_root + file_extension + syntax` - /// (v1.0 single-wiki shape, P16) + /// (legacy single-wiki shape) /// 3. `workspace_folders[0]` if present /// 4. deprecated `root_uri` if present /// 5. empty list (server stays alive but won't index anything) @@ -445,7 +443,7 @@ mod opt_bool_or_int { #[serde(default, rename_all = "snake_case")] struct InitOptions { wikis: Option>, - // v1.0 single-wiki compat fields + // legacy single-wiki compat fields wiki_root: Option, file_extension: Option, syntax: Option, @@ -466,7 +464,7 @@ struct RawWiki { diary_rel_path: Option, #[serde(default)] diary_index: Option, - // Phase 17 HTML keys — all optional, fall back to per-root defaults. + // HTML keys — all optional, fall back to per-root defaults. #[serde(default)] html_path: Option, #[serde(default)] @@ -487,7 +485,7 @@ struct RawWiki { exclude_files: Option>, #[serde(default)] color_dic: Option>, - // v1.1 per-wiki keys mirroring vimwiki globals. All optional so + // per-wiki keys mirroring vimwiki globals. All optional so // existing single-wiki configs migrate without touching anything. #[serde(default)] index: Option, diff --git a/crates/nuwiki-lsp/src/diagnostics.rs b/crates/nuwiki-lsp/src/diagnostics.rs index 267b234..31769e1 100644 --- a/crates/nuwiki-lsp/src/diagnostics.rs +++ b/crates/nuwiki-lsp/src/diagnostics.rs @@ -1,6 +1,6 @@ //! Diagnostic sources beyond parse-time `ErrorNode`s. //! -//! Phase 15 lands the `nuwiki.link` source — broken-link warnings. Walks +//! The `nuwiki.link` source emits broken-link warnings. Walks //! every `WikiLinkNode` in the AST and emits one diagnostic per: //! - `Wiki` target that doesn't resolve to a page in the workspace index, //! - `Wiki`/`AnchorOnly` target with an anchor that matches neither a @@ -8,7 +8,7 @@ //! - `File`/`Local` target whose resolved path doesn't exist on disk. //! //! Interwiki, Diary, Raw, and external links are not diagnosed — -//! interwiki resolution is Phase 18's job, diary is Phase 16's, and we +//! interwiki resolution and diary handling live elsewhere, and we //! don't fetch URLs. use std::path::PathBuf; @@ -332,8 +332,7 @@ fn resolve_file_path( index.root.as_ref().map(|r| r.join(&candidate)) } -/// `nuwiki.link` diagnostics for a single document. Phase 11 left this as -/// a stub; Phase 15 fills it in. +/// `nuwiki.link` diagnostics for a single document. pub fn link_health( ast: &DocumentNode, text: &str, diff --git a/crates/nuwiki-lsp/src/diary.rs b/crates/nuwiki-lsp/src/diary.rs index eda13e4..ed291f1 100644 --- a/crates/nuwiki-lsp/src/diary.rs +++ b/crates/nuwiki-lsp/src/diary.rs @@ -1,4 +1,4 @@ -//! Diary subsystem (Phase 16) — pure helpers used by the +//! Diary subsystem — pure helpers used by the //! `nuwiki.diary.*` `executeCommand` handlers. //! //! All path/URI math lives here so the command dispatcher stays a thin diff --git a/crates/nuwiki-lsp/src/edits.rs b/crates/nuwiki-lsp/src/edits.rs index 7aad9ba..7f449c6 100644 --- a/crates/nuwiki-lsp/src/edits.rs +++ b/crates/nuwiki-lsp/src/edits.rs @@ -1,6 +1,6 @@ //! Helpers for building `WorkspaceEdit`s. //! -//! Phase 13+ (rename, list/table edits, link/TOC generation) all return +//! Rename, list/table edits, and link/TOC generation all return //! `WorkspaceEdit`. The builder centralises the byte-offset → LSP-position //! conversion (UTF-8 or UTF-16, matching whatever was negotiated in //! `initialize`) so each call site doesn't have to redo it. diff --git a/crates/nuwiki-lsp/src/export.rs b/crates/nuwiki-lsp/src/export.rs index 3ceeae4..88de5a8 100644 --- a/crates/nuwiki-lsp/src/export.rs +++ b/crates/nuwiki-lsp/src/export.rs @@ -1,4 +1,4 @@ -//! HTML export — pure helpers shared by the Phase 17 `nuwiki.export.*` +//! HTML export — pure helpers shared by the `nuwiki.export.*` //! command handlers. Side-effecting work (reading the template from //! disk, writing the rendered HTML, copying CSS) lives in `commands.rs` //! since the dispatcher is the one with a `&Backend` and async context. diff --git a/crates/nuwiki-lsp/src/folding.rs b/crates/nuwiki-lsp/src/folding.rs index 49b1718..e9cf32c 100644 --- a/crates/nuwiki-lsp/src/folding.rs +++ b/crates/nuwiki-lsp/src/folding.rs @@ -1,4 +1,4 @@ -//! `textDocument/foldingRange` provider — SPEC §12.10 / P14. +//! `textDocument/foldingRange` provider. //! //! Strategy: one fold per heading (line → line before the next heading //! of same-or-higher level) plus one fold per top-level list block. diff --git a/crates/nuwiki-lsp/src/index.rs b/crates/nuwiki-lsp/src/index.rs index 732192f..5fc154a 100644 --- a/crates/nuwiki-lsp/src/index.rs +++ b/crates/nuwiki-lsp/src/index.rs @@ -1,7 +1,7 @@ //! Workspace index — the in-memory model of every `.wiki` page nuwiki -//! has seen, used to power Phase 8 nav features. +//! has seen, used to power nav features. //! -//! Per P8 (SPEC §11), the initial scan runs as a background tokio task so +//! The initial scan runs as a background tokio task so //! the server stays responsive on startup. Per-document updates land here //! synchronously via `upsert` whenever the LSP backend re-parses on //! `didOpen` / `didChange`. @@ -29,10 +29,10 @@ pub struct IndexedPage { pub title: Option, pub headings: Vec, pub outgoing: Vec, - /// Phase 12: every `TagNode` on the page. `tags_by_name` on the + /// Every `TagNode` on the page. `tags_by_name` on the /// containing `WorkspaceIndex` is the reverse map. pub tags: Vec, - /// Phase 16: `Some(date)` when this page is a *daily* diary entry — + /// `Some(date)` when this page is a *daily* diary entry — /// stem parses as `YYYY-MM-DD`. Equivalent to /// `diary_period.and_then(|p| if let DiaryPeriod::Day(d) = p { Some(d) } else { None })`. /// Kept as its own field for back-compat with prev/next/list callers @@ -97,7 +97,7 @@ pub struct Backlink { } /// A tag's location, used by `WorkspaceIndex::tags_for` for cross-page -/// `[[Page#tag]]` resolution and `nuwiki.tags.search` (Phase 13). +/// `[[Page#tag]]` resolution and `nuwiki.tags.search`. #[derive(Debug, Clone)] pub struct TagOccurrence { pub uri: Url, @@ -108,7 +108,7 @@ pub struct TagOccurrence { #[derive(Debug, Default)] pub struct WorkspaceIndex { pub root: Option, - /// Phase 16: subdir relative to `root` that holds diary entries. + /// Subdir relative to `root` that holds diary entries. /// Mirrors `WikiConfig::diary_rel_path`; stored here so `upsert` can /// classify each indexed URI without a back-reference to the config. pub diary_rel_path: Option, @@ -119,9 +119,9 @@ pub struct WorkspaceIndex { pub pages_by_uri: HashMap, pub pages_by_name: HashMap, pub backlinks: HashMap>, - /// Phase 12: tag name → every occurrence across the workspace. Used by - /// the v1.1 nav handlers (tag-as-anchor `definition`, `workspace/symbol` - /// inclusion) and the Phase 13 `nuwiki.tags.search` command. + /// Tag name → every occurrence across the workspace. Used by + /// the nav handlers (tag-as-anchor `definition`, `workspace/symbol` + /// inclusion) and the `nuwiki.tags.search` command. pub tags_by_name: HashMap>, } @@ -187,7 +187,7 @@ impl WorkspaceIndex { } } - // Phase 12: maintain the reverse tag map alongside the per-page list. + // Maintain the reverse tag map alongside the per-page list. for tag in &page.tags { self.tags_by_name .entry(tag.name.clone()) diff --git a/crates/nuwiki-lsp/src/lib.rs b/crates/nuwiki-lsp/src/lib.rs index a4dd1e4..b9356a3 100644 --- a/crates/nuwiki-lsp/src/lib.rs +++ b/crates/nuwiki-lsp/src/lib.rs @@ -1,8 +1,7 @@ //! LSP protocol bridge for nuwiki. //! -//! `tower-lsp` server that maintains a `DocumentStore` (SPEC §6.10) plus a -//! workspace-wide index (SPEC §6.10 + P8) and exposes every LSP method -//! through Phase 8: +//! `tower-lsp` server that maintains a `DocumentStore` plus a +//! workspace-wide index and exposes every LSP method: //! //! - `initialize` / `initialized` / `shutdown` //! - `textDocument/didOpen`, `didChange` (full sync), `didClose` @@ -14,12 +13,12 @@ //! - `workspace/symbol` — search across indexed headings //! //! Position encoding is negotiated as UTF-8 when the client supports LSP -//! 3.17+ encodings (SPEC §6.11). When the client only supports the legacy +//! 3.17+ encodings. When the client only supports the legacy //! UTF-16 default, positions get translated through a per-line lookup. //! -//! Re-parses are full per change (incremental parsing deferred post-v1). +//! Re-parses are full per change (no incremental parsing). //! The workspace scan runs in a background tokio task spawned from -//! `initialize` (P8) with `window/workDoneProgress` begin/end events. +//! `initialize` with `window/workDoneProgress` begin/end events. pub mod commands; pub mod config; @@ -111,8 +110,8 @@ pub async fn run_stdio() -> io::Result<()> { struct DocumentState { text: String, ast: DocumentNode, - /// Last version we observed from the client. Held per SPEC §6.10 even - /// though the foundation phase doesn't yet need to read it back. + /// Last version we observed from the client. Held even + /// though nothing reads it back yet. #[allow(dead_code)] version: i32, } @@ -124,8 +123,8 @@ struct Backend { /// True after `initialize` if the client opted into UTF-8 encoding. /// Otherwise we keep the LSP 3.16 default of UTF-16. use_utf8: Arc, - /// Per-wiki state (P11). Vector holds one entry until Phase 18 - /// (multi-wiki) lets users add more. + /// Per-wiki state. Vector holds one entry until multi-wiki + /// support lets users add more. wikis: Arc>>, /// Server config received via `initializationOptions` and refreshed /// via `workspace/didChangeConfiguration` (P18). @@ -151,7 +150,7 @@ impl Backend { wikis.iter().find(|w| w.id == id).cloned() } - /// Used by Phase 15 workspace-level commands (`checkLinks`, `findOrphans`) + /// Used by workspace-level commands (`checkLinks`, `findOrphans`) /// when no URI is supplied — they fall back to the first registered wiki. fn default_wiki(&self) -> Option { let wikis = self.wikis.read().ok()?; @@ -290,7 +289,7 @@ impl Backend { /// Return live state if `uri` is open in the editor, otherwise read /// the file from disk and re-parse. The closed-doc path is used by - /// cross-document operations (Phase 13 `workspace/rename` and link + /// cross-document operations (`workspace/rename` and link /// health) that need accurate spans + text without trusting cached /// `WorkspaceIndex` data. pub async fn read_or_open(&self, uri: &Url) -> io::Result { @@ -318,7 +317,7 @@ impl Backend { } async fn update_document(&self, uri: Url, text: String, version: i32) { - // For Phase 6 we always treat unknown buffers as vimwiki — if/when + // We always treat unknown buffers as vimwiki — if/when // multi-syntax dispatch lands the pick should follow the URI's ext. let plugin = match self.registry.get("vimwiki") { Some(p) => p, @@ -373,7 +372,7 @@ impl Backend { #[tower_lsp::async_trait] impl LanguageServer for Backend { async fn initialize(&self, params: InitializeParams) -> LspResult { - // SPEC §6.11: prefer UTF-8 if the client advertises support. + // Prefer UTF-8 if the client advertises support. let supports_utf8 = params .capabilities .general @@ -404,7 +403,7 @@ impl LanguageServer for Backend { change: Some(TextDocumentSyncKind::FULL), will_save: None, will_save_wait_until: None, - // Phase 17 `auto_export` listens on `did_save`. Asking + // `auto_export` listens on `did_save`. Asking // for `include_text: false` since the document store // already mirrors the live buffer. save: Some(TextDocumentSyncSaveOptions::SaveOptions(SaveOptions { @@ -502,9 +501,9 @@ impl LanguageServer for Backend { async fn did_change_configuration(&self, params: DidChangeConfigurationParams) { // Apply the new settings to `Config`. Re-indexing on root changes - // is out of scope for Phase 11; for now we just accept the new + // is out of scope here; for now we just accept the new // values so later commands see the right diagnostic settings etc. - // Phase 18 (multi-wiki) revisits this with proper rebuild semantics. + // Multi-wiki support revisits this with proper rebuild semantics. let mut cfg = self.config.write().expect("config lock poisoned"); cfg.apply_change(¶ms.settings); } @@ -530,7 +529,7 @@ impl LanguageServer for Backend { } async fn did_rename_files(&self, params: RenameFilesParams) { - // Phase 13 advertises this capability so the editor pushes + // We advertise this capability so the editor pushes // out-of-band renames (file explorer drags, `:VimwikiRenameFile` // when the client opts to surface the new URI). Update the index // so backlinks/headings track the new URI without waiting for @@ -576,7 +575,7 @@ impl LanguageServer for Backend { } async fn did_save(&self, params: DidSaveTextDocumentParams) { - // Phase 17: when `auto_export` is set on the resolved wiki, run + // When `auto_export` is set on the resolved wiki, run // the equivalent of `nuwiki.export.currentToHtml` after the file // hits disk. Best-effort — failures are logged but do not bubble // to the client. Skips pages with the `%nohtml` directive. @@ -934,7 +933,7 @@ impl LanguageServer for Backend { }); } } - // Phase 12: surface tags too, named as `:tag:` so editors + // Surface tags too, named as `:tag:` so editors // can distinguish them from headings at a glance. for t in &page.tags { if q.is_empty() || t.name.to_lowercase().contains(&q) { @@ -1121,9 +1120,9 @@ fn utf16_column(text: &str, line: u32, byte_col: u32) -> u32 { .sum::() as u32 } -/// Backward-compat wrapper kept for v1.0 test surface. New call sites +/// Backward-compat wrapper kept for the legacy test surface. New call sites /// should use `collect_diagnostics` so they can opt into link-health -/// (Phase 15) and other sources. +/// and other sources. pub fn ast_diagnostics(ast: &DocumentNode, text: &str, utf8: bool) -> Vec { collect_diagnostics( ast, @@ -1136,12 +1135,12 @@ pub fn ast_diagnostics(ast: &DocumentNode, text: &str, utf8: bool) -> Vec &'static str { // ===== Workspace + navigation helpers ===== // -// `workspace_root_from_params` lived here through Phase 10; Phase 11 lifted -// that logic into `Config::from_init_params` (see `config.rs`) so workspace +// `workspace_root_from_params` logic now lives in +// `Config::from_init_params` (see `config.rs`) so workspace // folders and `root_uri` are handled alongside `initializationOptions`. /// Background workspace scan (P8). Walks every `.wiki` file under `root`, @@ -1469,7 +1468,7 @@ fn heading_range_in(idx: &WorkspaceIndex, uri: &Url, anchor: &str, _utf8: bool) if let Some(h) = page.find_heading_by_anchor(anchor) { return Some(span_to_lsp_range_no_text(&h.span)); } - // Phase 12: tags-as-anchors. `[[Page#tag-name]]` lands on the + // Tags-as-anchors. `[[Page#tag-name]]` lands on the // matching `TagNode` on the target page. let t = page.tags.iter().find(|t| t.name == anchor)?; Some(span_to_lsp_range_no_text(&t.span)) diff --git a/crates/nuwiki-lsp/src/nav.rs b/crates/nuwiki-lsp/src/nav.rs index 55941eb..6042527 100644 --- a/crates/nuwiki-lsp/src/nav.rs +++ b/crates/nuwiki-lsp/src/nav.rs @@ -1,4 +1,4 @@ -//! Helpers shared by the Phase 8 navigation handlers (definition, +//! Helpers shared by the navigation handlers (definition, //! references, hover, completion). //! //! Two responsibilities: diff --git a/crates/nuwiki-lsp/src/rename.rs b/crates/nuwiki-lsp/src/rename.rs index 4490a20..a2be058 100644 --- a/crates/nuwiki-lsp/src/rename.rs +++ b/crates/nuwiki-lsp/src/rename.rs @@ -1,12 +1,12 @@ //! `textDocument/rename` — server-side rename with cross-document link //! rewriting. //! -//! Phase 13 implementation: +//! Implementation: //! 1. Resolve the rename target — cursor on a wikilink renames the linked //! page; otherwise rename the current file. //! 2. Build the new URI from the wiki root + the user-supplied path. //! 3. For every page in the wiki's index with an outgoing link to the -//! old page name, read its source via the Phase 11 closed-doc loader +//! old page name, read its source via the closed-doc loader //! so cached spans are validated against current text, then emit a //! `TextEdit` that swaps the `[[old]]` for `[[new]]` while preserving //! anchors and descriptions. diff --git a/crates/nuwiki-lsp/src/semantic_tokens.rs b/crates/nuwiki-lsp/src/semantic_tokens.rs index fc26b31..8f2a4ce 100644 --- a/crates/nuwiki-lsp/src/semantic_tokens.rs +++ b/crates/nuwiki-lsp/src/semantic_tokens.rs @@ -1,8 +1,7 @@ //! Semantic-token emission for the vimwiki AST. //! -//! Token-type scheme settled in P7: custom `vimwiki*` types plus -//! `level1`..`level6` + `centered` modifiers (SPEC §11). Default highlight -//! groups for these arrive in Phase 9. +//! Token-type scheme: custom `vimwiki*` types plus +//! `level1`..`level6` + `centered` modifiers. //! //! Pipeline: //! diff --git a/crates/nuwiki-lsp/src/wiki.rs b/crates/nuwiki-lsp/src/wiki.rs index efe1e6b..16d95b8 100644 --- a/crates/nuwiki-lsp/src/wiki.rs +++ b/crates/nuwiki-lsp/src/wiki.rs @@ -1,9 +1,9 @@ //! `Wiki` aggregate — per-wiki state, even when there's only one. //! -//! v1.0 shipped with a single `Arc>` on `Backend`. -//! Phase 11 lifts that into a `Wiki` aggregate so Phase 18 (multi-wiki) -//! only changes config shape, not data flow. Phases 12–17 always operate -//! on a `Wiki`; in practice the `wikis` `Vec` has one entry until 18. +//! The single `Arc>` on `Backend` is lifted into a +//! `Wiki` aggregate so multi-wiki support only changes config shape, not +//! data flow. Handlers always operate on a `Wiki`; in practice the `wikis` +//! `Vec` has one entry until multi-wiki support lands. use std::path::Path; use std::sync::{Arc, RwLock}; diff --git a/crates/nuwiki-lsp/tests/commands_checkboxes.rs b/crates/nuwiki-lsp/tests/commands_checkboxes.rs index 5028e00..363d951 100644 --- a/crates/nuwiki-lsp/tests/commands_checkboxes.rs +++ b/crates/nuwiki-lsp/tests/commands_checkboxes.rs @@ -47,6 +47,24 @@ fn cycle_state_walks_progression() { assert_eq!(ops::cycle_state("[-]"), Some("[ ]")); } +#[test] +fn cycle_state_back_is_exact_inverse() { + // `glp` walks the progression in reverse — every step must undo the + // matching `cycle_state` step. + assert_eq!(ops::cycle_state_back("[ ]"), Some("[X]")); + assert_eq!(ops::cycle_state_back("[X]"), Some("[O]")); + assert_eq!(ops::cycle_state_back("[O]"), Some("[o]")); + assert_eq!(ops::cycle_state_back("[o]"), Some("[.]")); + assert_eq!(ops::cycle_state_back("[.]"), Some("[ ]")); + assert_eq!(ops::cycle_state_back("[-]"), Some("[ ]")); + // Composing forward then backward returns to the start for every + // in-cycle marker. + for s in ["[ ]", "[.]", "[o]", "[O]", "[X]"] { + let fwd = ops::cycle_state(s).unwrap(); + assert_eq!(ops::cycle_state_back(fwd), Some(s)); + } +} + #[test] fn reject_state_toggles_dash_marker() { assert_eq!(ops::reject_state("[ ]"), Some("[-]")); diff --git a/crates/nuwiki-lsp/tests/commands_colorize.rs b/crates/nuwiki-lsp/tests/commands_colorize.rs index c08085b..60a8000 100644 --- a/crates/nuwiki-lsp/tests/commands_colorize.rs +++ b/crates/nuwiki-lsp/tests/commands_colorize.rs @@ -1,9 +1,9 @@ -//! §13.1 §13.3 — `color_dic` → `HtmlRenderer` integration. +//! `color_dic` → `HtmlRenderer` integration. //! //! Drives the renderer directly so we don't have to spin up the full //! export pipeline. The end-to-end path (export command → renderer //! with `cfg.color_dic` plumbed through) is integration-tested via the -//! Phase 17 export tests. +//! export tests. use std::collections::HashMap; diff --git a/crates/nuwiki-lsp/tests/commands_coverage.rs b/crates/nuwiki-lsp/tests/commands_coverage.rs new file mode 100644 index 0000000..6aca03a --- /dev/null +++ b/crates/nuwiki-lsp/tests/commands_coverage.rs @@ -0,0 +1,532 @@ +//! Command-coverage suite: at least one behavioural test for every +//! documented `:Nuwiki*` / `:Vimwiki*` command (see the COMMANDS section +//! of `doc/nuwiki.txt`). +//! +//! The user-facing commands funnel through `workspace/executeCommand` +//! into the Backend dispatcher, or — for follow/backlinks/rename — through +//! the standard LSP requests. The Backend itself owns a live `Client` and +//! can't be built in an integration test, so each case drives the *pure* +//! building-block the command relies on (the same functions the dispatcher +//! wraps). This file is organised to mirror the doc's command groups so a +//! reader can confirm every command has coverage. + +use std::collections::HashMap; +use std::path::PathBuf; + +use nuwiki_core::ast::{InlineNode, LinkKind, LinkTarget}; +use nuwiki_core::date::DiaryDate; +use nuwiki_core::syntax::vimwiki::VimwikiSyntax; +use nuwiki_core::syntax::SyntaxPlugin; +use nuwiki_lsp::commands::{export_ops, ops, COMMANDS}; +use nuwiki_lsp::config::WikiConfig; +use nuwiki_lsp::index::WorkspaceIndex; +use nuwiki_lsp::nav::find_inline_at; +use nuwiki_lsp::rename::{build_new_uri, rewrite_wikilink_target}; +use nuwiki_lsp::wiki::{build_wikis, resolve_uri_to_wiki}; +use nuwiki_lsp::{diary, export}; +use tower_lsp::lsp_types::Url; + +fn parse(src: &str) -> nuwiki_core::ast::DocumentNode { + VimwikiSyntax::new().parse(src) +} + +fn wiki_cfg(root: &str) -> WikiConfig { + WikiConfig::from_root(PathBuf::from(root)) +} + +/// Build a workspace index rooted at `root` with the given `name -> source` +/// pages, mirroring the helper used by the other command suites. +fn build_index(root: &str, pages: &[(&str, &str)]) -> WorkspaceIndex { + let mut idx = WorkspaceIndex::new(Some(PathBuf::from(root))); + for (name, src) in pages { + let uri = Url::from_file_path(format!("{root}/{name}.wiki")).unwrap(); + idx.upsert(uri, &parse(src)); + } + idx +} + +fn diary_index(root: &str, dates: &[&str]) -> WorkspaceIndex { + let mut idx = + WorkspaceIndex::new(Some(PathBuf::from(root))).with_diary_rel_path(Some("diary".into())); + for d in dates { + let uri = Url::from_file_path(format!("{root}/diary/{d}.wiki")).unwrap(); + idx.upsert(uri, &parse("= entry =\n")); + } + idx +} + +// ===================================================================== +// Group 1: Wiki / navigation +// ===================================================================== + +// :NuwikiIndex — open wiki N's index page. +#[test] +fn nuwiki_index_resolves_index_page() { + let idx = build_index("/wiki", &[("index", "= Home =\n"), ("Other", "= O =\n")]); + let page = idx.page_by_name("index").expect("index page indexed"); + assert!(page.uri.as_str().ends_with("/wiki/index.wiki")); +} + +// :NuwikiTabIndex — same target as :NuwikiIndex, opened in a new tab. +// The tab-vs-current split is an editor concern; the resolved target is +// identical, and both verbs are advertised as executeCommands. +#[test] +fn nuwiki_tab_index_advertised_alongside_index() { + assert!(COMMANDS.contains(&"nuwiki.wiki.openIndex")); + assert!(COMMANDS.contains(&"nuwiki.wiki.tabOpenIndex")); +} + +// :NuwikiUISelect — pick a wiki from the configured list. +#[test] +fn nuwiki_ui_select_lists_configured_wikis() { + let cfgs = vec![wiki_cfg("/a/personal"), wiki_cfg("/b/work")]; + let wikis = build_wikis(&cfgs); + assert_eq!(wikis.len(), 2); + assert_eq!(wikis[0].config.name, "personal"); + assert_eq!(wikis[1].config.name, "work"); + + // Selection by URI picks the wiki that actually owns the file. + let uri = Url::from_file_path("/b/work/Page.wiki").unwrap(); + assert_eq!(resolve_uri_to_wiki(&wikis, &uri), Some(wikis[1].id)); +} + +// :NuwikiGoto {page} — open `{page}.wiki` by name. +#[test] +fn nuwiki_goto_opens_page_by_name() { + let idx = build_index("/wiki", &[("index", "= H =\n"), ("Recipes", "= R =\n")]); + let page = idx + .page_by_name("Recipes") + .expect("page resolvable by name"); + assert!(page.uri.as_str().ends_with("/wiki/Recipes.wiki")); + assert!(idx.page_by_name("Missing").is_none()); +} + +// :NuwikiFollowLink — follow the link under the cursor. +#[test] +fn nuwiki_follow_link_resolves_target_under_cursor() { + let idx = build_index( + "/wiki", + &[("Home", "see [[About]]\n"), ("About", "= A =\n")], + ); + let doc = parse("see [[About]]\n"); + // Byte col 8 sits inside "[[About]]" (starts at byte 4). + let node = find_inline_at(&doc, 0, 8).expect("wikilink under cursor"); + let InlineNode::WikiLink(w) = node else { + panic!("expected a wikilink, got {node:?}"); + }; + let about = Url::from_file_path("/wiki/About.wiki").unwrap(); + assert_eq!(idx.resolve(&w.target, "Home"), Some(&about)); +} + +// :NuwikiBacklinks — every reference to the current page. +#[test] +fn nuwiki_backlinks_lists_referrers() { + let idx = build_index( + "/wiki", + &[ + ("Home", "[[About]]\n"), + ("Contact", "see [[About]] too\n"), + ("About", "= A =\n"), + ], + ); + let back = idx.backlinks_for("About"); + assert_eq!(back.len(), 2); + assert!(idx.backlinks_for("Home").is_empty()); +} + +// :NuwikiNextLink / :NuwikiPrevLink — jump to the next / previous wikilink. +// The jump itself is editor-side cursor movement; what the server provides +// is the ordered set of links on the page. +#[test] +fn nuwiki_next_prev_link_walk_links_in_document_order() { + let doc = parse("intro [[First]] mid [[Second]] end [[Third]]\n"); + let links = ops::collect_wiki_links(&doc); + let targets: Vec<&str> = links + .iter() + .filter_map(|l| l.target.path.as_deref()) + .collect(); + assert_eq!(targets, vec!["First", "Second", "Third"]); +} + +// :NuwikiBaddLink — add the target of the link under the cursor to the +// buffer list. The editor :badds whatever URI the link resolves to. +#[test] +fn nuwiki_badd_link_resolves_target_uri() { + let idx = build_index( + "/wiki", + &[("Home", "[[Notes/Todo]]\n"), ("Notes/Todo", "= T =\n")], + ); + let target = LinkTarget { + kind: LinkKind::Wiki, + path: Some("Notes/Todo".into()), + ..Default::default() + }; + let expected = Url::from_file_path("/wiki/Notes/Todo.wiki").unwrap(); + assert_eq!(idx.resolve(&target, "Home"), Some(&expected)); +} + +// :NuwikiRenameFile — rename the page and rewrite every inbound link. +#[test] +fn nuwiki_rename_file_builds_uri_and_rewrites_links() { + let new_uri = build_new_uri(&PathBuf::from("/wiki"), "New Name", ".wiki").unwrap(); + assert!(new_uri.as_str().ends_with("/wiki/New%20Name.wiki")); + + assert_eq!( + rewrite_wikilink_target("[[Old Name|caption]]", "New Name"), + Some("[[New Name|caption]]".to_string()) + ); +} + +// :NuwikiDeleteFile — delete the current page and its on-disk file. +#[test] +fn nuwiki_delete_file_emits_delete_workspace_edit() { + use tower_lsp::lsp_types::{DocumentChangeOperation, DocumentChanges, ResourceOp}; + let uri = Url::parse("file:///wiki/Doomed.wiki").unwrap(); + let mut b = nuwiki_lsp::edits::WorkspaceEditBuilder::new(); + b.file_op(nuwiki_lsp::edits::op_delete(uri.clone())); + let we = b.build(); + + let DocumentChanges::Operations(ops) = we.document_changes.unwrap() else { + panic!("expected resource operations"); + }; + assert!(matches!( + &ops[0], + DocumentChangeOperation::Op(ResourceOp::Delete(d)) if d.uri == uri + )); +} + +// ===================================================================== +// Group 2: Diary +// ===================================================================== + +// :NuwikiMakeDiaryNote — open today's entry. +#[test] +fn nuwiki_make_diary_note_targets_todays_file() { + let cfg = wiki_cfg("/wiki"); + let today = DiaryDate::from_ymd(2026, 5, 12).unwrap(); + let uri = diary::uri_for_date(&cfg, &today).unwrap(); + assert!(uri.as_str().ends_with("/diary/2026-05-12.wiki")); +} + +// :NuwikiMakeYesterdayDiaryNote / :NuwikiMakeTomorrowDiaryNote — step the +// diary back / forward by one period at the daily cadence. +#[test] +fn nuwiki_yesterday_and_tomorrow_target_adjacent_files() { + let cfg = wiki_cfg("/wiki"); + let yesterday = DiaryDate::from_ymd(2026, 5, 11).unwrap(); + let tomorrow = DiaryDate::from_ymd(2026, 5, 13).unwrap(); + assert!(diary::uri_for_date(&cfg, &yesterday) + .unwrap() + .as_str() + .ends_with("/diary/2026-05-11.wiki")); + assert!(diary::uri_for_date(&cfg, &tomorrow) + .unwrap() + .as_str() + .ends_with("/diary/2026-05-13.wiki")); +} + +// :NuwikiDiaryIndex — open the diary index page. +#[test] +fn nuwiki_diary_index_resolves_index_uri() { + let cfg = wiki_cfg("/wiki"); + let uri = diary::index_uri(&cfg).unwrap(); + assert!(uri.as_str().ends_with("/diary/diary.wiki")); +} + +// :NuwikiDiaryGenerateLinks — rebuild the diary index from disk entries. +#[test] +fn nuwiki_diary_generate_links_builds_grouped_body() { + let idx = diary_index("/wiki", &["2026-05-10", "2026-05-12", "2026-04-30"]); + let entries = diary::list_entries(&idx); + let body = diary::build_index_body(&entries, "diary", "Diary"); + assert!(body.starts_with("= Diary =")); + assert!(body.contains("2026-05-12")); + assert!(body.contains("2026-05-10")); + assert!(body.contains("2026-04-30")); + // Newest entry appears before the older one within the same month. + let p12 = body.find("2026-05-12").unwrap(); + let p10 = body.find("2026-05-10").unwrap(); + assert!(p12 < p10, "expected newest-first ordering"); +} + +// :NuwikiDiaryNextDay / :NuwikiDiaryPrevDay — walk indexed entries. +#[test] +fn nuwiki_diary_next_and_prev_day_walk_entries() { + let idx = diary_index("/wiki", &["2026-05-10", "2026-05-12", "2026-05-15"]); + let from = DiaryDate::from_ymd(2026, 5, 12).unwrap(); + assert_eq!( + diary::next_entry(&idx, &from).unwrap().date.format(), + "2026-05-15" + ); + assert_eq!( + diary::prev_entry(&idx, &from).unwrap().date.format(), + "2026-05-10" + ); +} + +// ===================================================================== +// Group 3: Page generation +// ===================================================================== + +// :NuwikiTOC — generate / refresh the table of contents. +#[test] +fn nuwiki_toc_generates_nested_contents() { + let src = "= Top =\n== Sub ==\ntext\n"; + let doc = parse(src); + let uri = Url::from_file_path("/wiki/Page.wiki").unwrap(); + let edit = ops::toc_edit(src, &doc, &uri, true).expect("toc edit produced"); + assert!(edit.changes.is_some() || edit.document_changes.is_some()); + + let toc = ops::build_toc_text( + &[ + (1, "Top".into(), "top".into()), + (2, "Sub".into(), "sub".into()), + ], + "Contents", + ); + assert!(toc.contains("= Contents =")); + assert!(toc.contains("- [[#top|Top]]")); + assert!(toc.contains(" - [[#sub|Sub]]")); +} + +// :NuwikiGenerateLinks — insert a flat list of every page in the wiki. +#[test] +fn nuwiki_generate_links_lists_all_pages_excluding_current() { + let pages = vec!["About".to_string(), "Home".to_string(), "Notes".to_string()]; + let text = ops::build_links_text(&pages, "Links", Some("Home")); + assert!(text.contains("= Links =")); + assert!(text.contains("- [[About]]")); + assert!(text.contains("- [[Notes]]")); + assert!(!text.contains("[[Home]]"), "current page excluded"); + + let src = "= Existing =\n"; + let doc = parse(src); + let uri = Url::from_file_path("/wiki/Home.wiki").unwrap(); + assert!(ops::links_edit(src, &doc, &uri, "Home", &pages, true).is_some()); +} + +// :NuwikiCheckLinks — surface broken links across the workspace. +#[test] +fn nuwiki_check_links_distinguishes_broken_from_resolvable() { + let idx = build_index( + "/wiki", + &[("Home", "[[About]] [[Ghost]]\n"), ("About", "= A =\n")], + ); + let good = LinkTarget { + kind: LinkKind::Wiki, + path: Some("About".into()), + ..Default::default() + }; + let broken = LinkTarget { + kind: LinkKind::Wiki, + path: Some("Ghost".into()), + ..Default::default() + }; + assert!(idx.resolve(&good, "Home").is_some()); + assert!( + idx.resolve(&broken, "Home").is_none(), + "broken link unresolved" + ); +} + +// ===================================================================== +// Group 4: Tags +// ===================================================================== + +// :NuwikiSearchTags {tag} — every `:tag:` occurrence. +#[test] +fn nuwiki_search_tags_finds_occurrences() { + let idx = build_index( + "/wiki", + &[("Home", ":alpha:beta:\n"), ("Work", ":alpha:\n")], + ); + let hits = ops::tag_hits(&idx, "alpha"); + assert_eq!(hits.len(), 2); + assert!(hits.iter().all(|h| h.name == "alpha")); + assert!(ops::tag_hits(&idx, "nonexistent").is_empty()); +} + +// :NuwikiGenerateTagLinks [tag] — section linking every page with a tag. +#[test] +fn nuwiki_generate_tag_links_builds_section() { + let idx = build_index( + "/wiki", + &[ + ("Home", ":alpha:\n"), + ("Work", ":alpha:\n"), + ("Misc", ":beta:\n"), + ], + ); + let by_tag = ops::tag_pages_snapshot(&idx); + + let single = ops::build_tag_links_text(&by_tag, Some("alpha")).unwrap(); + assert!(single.starts_with("= Tag: alpha =")); + assert!(single.contains("- [[Home]]")); + assert!(single.contains("- [[Work]]")); + + // No-arg variant emits a section per tag. + let all = ops::build_tag_links_text(&by_tag, None).unwrap(); + assert!(all.starts_with("= Tags =")); + assert!(all.contains("== alpha ==")); + assert!(all.contains("== beta ==")); + + let src = "= Home =\n"; + let doc = parse(src); + let uri = Url::from_file_path("/wiki/Home.wiki").unwrap(); + assert!(ops::tag_links_edit(src, &doc, &uri, Some("alpha"), &by_tag, true).is_some()); +} + +// :NuwikiRebuildTags — force a full workspace re-index. A rebuild must +// reflect the current on-disk tags, dropping stale ones. +#[test] +fn nuwiki_rebuild_tags_reflects_current_state() { + let mut idx = WorkspaceIndex::new(Some(PathBuf::from("/wiki"))); + let uri = Url::from_file_path("/wiki/Home.wiki").unwrap(); + idx.upsert(uri.clone(), &parse(":old:\n")); + assert_eq!(idx.tags_for("old").len(), 1); + + // Re-index after the page changed on disk. + idx.upsert(uri, &parse(":new:\n")); + assert!(idx.tags_for("old").is_empty(), "stale tag dropped"); + assert_eq!(idx.tags_for("new").len(), 1); +} + +// ===================================================================== +// Group 5: HTML export +// ===================================================================== + +// :Nuwiki2HTML — render the current page to HTML. +#[test] +fn nuwiki_2html_renders_page() { + let cfg = wiki_cfg("/wiki"); + let doc = parse("%title My Page\n= One =\nbody text\n"); + let html = export::render_page_html( + &doc, + Some("{{title}}
{{content}}
".into()), + "Home", + DiaryDate::from_ymd(2026, 5, 12).unwrap(), + &cfg.html, + Some(".wiki"), + HashMap::new(), + ) + .unwrap(); + assert!(html.contains("My Page")); + assert!(html.contains("body text")); +} + +// :Nuwiki2HTMLBrowse — render then open in the browser. The render output +// is identical to :Nuwiki2HTML; the browse step is an editor-side open. +#[test] +fn nuwiki_2html_browse_shares_render_and_is_advertised() { + assert!(COMMANDS.contains(&"nuwiki.export.browse")); + let cfg = wiki_cfg("/wiki"); + let doc = parse("= Page =\nhi\n"); + let html = export::render_page_html( + &doc, + Some("{{content}}".into()), + "Page", + DiaryDate::today_utc(), + &cfg.html, + Some(".wiki"), + HashMap::new(), + ) + .unwrap(); + assert!(html.contains("hi")); +} + +// :NuwikiAll2HTML[!] — export every page; honour the exclude list and +// per-page output paths. +#[test] +fn nuwiki_all2html_maps_output_paths_and_excludes() { + let cfg = wiki_cfg("/wiki"); + assert!(export::output_path_for(&cfg.html, "Home").ends_with("Home.html")); + assert!( + export::output_path_for(&cfg.html, "diary/2026-05-12").ends_with("diary/2026-05-12.html") + ); + + let patterns = vec!["_drafts/**".to_string()]; + assert!(export::is_excluded(&patterns, "_drafts/secret")); + assert!(!export::is_excluded(&patterns, "Home")); + + // Both incremental and forced (`!`) variants are advertised. + assert!(COMMANDS.contains(&"nuwiki.export.allToHtml")); + assert!(COMMANDS.contains(&"nuwiki.export.allToHtmlForce")); +} + +// :NuwikiRss — write `rss.xml` summarising recent diary entries. +#[test] +fn nuwiki_rss_writes_feed_file() { + let root = std::env::temp_dir().join(format!("nuwiki-cov-rss-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + let cfg = WikiConfig::from_root(root.clone()); + + let entries = vec![ + diary::DiaryEntry { + date: DiaryDate::from_ymd(2026, 5, 10).unwrap(), + uri: Url::from_file_path(root.join("diary/2026-05-10.wiki")).unwrap(), + }, + diary::DiaryEntry { + date: DiaryDate::from_ymd(2026, 5, 12).unwrap(), + uri: Url::from_file_path(root.join("diary/2026-05-12.wiki")).unwrap(), + }, + ]; + + let path = export_ops::write_rss(&cfg, &entries).unwrap(); + assert!(path.ends_with("rss.xml")); + let xml = std::fs::read_to_string(&path).unwrap(); + assert!(xml.contains("")); + // Newest entry listed first. + let p12 = xml.find("2026-05-12").unwrap(); + let p10 = xml.find("2026-05-10").unwrap(); + assert!(p12 < p10); + + let _ = std::fs::remove_dir_all(&root); +} + +// ===================================================================== +// Group 6: Other +// ===================================================================== + +// :NuwikiInstall — install/re-install the `nuwiki-ls` binary. This is an +// editor-side action (binary download / cargo build); it is deliberately +// NOT an LSP executeCommand, so the server must not advertise it. +#[test] +fn nuwiki_install_is_editor_only_not_an_lsp_command() { + assert!(!COMMANDS.iter().any(|c| c.contains("install"))); +} + +// ===================================================================== +// Command-surface cross-check: every documented command that maps to an +// executeCommand handler is actually advertised by the server. +// ===================================================================== + +#[test] +fn every_documented_lsp_command_is_advertised() { + let expected = [ + "nuwiki.wiki.openIndex", // :NuwikiIndex + "nuwiki.wiki.tabOpenIndex", // :NuwikiTabIndex + "nuwiki.wiki.listAll", // :NuwikiUISelect + "nuwiki.wiki.gotoPage", // :NuwikiGoto + "nuwiki.file.delete", // :NuwikiDeleteFile + "nuwiki.diary.openToday", // :NuwikiMakeDiaryNote + "nuwiki.diary.openYesterday", // :NuwikiMakeYesterdayDiaryNote + "nuwiki.diary.openTomorrow", // :NuwikiMakeTomorrowDiaryNote + "nuwiki.diary.openIndex", // :NuwikiDiaryIndex + "nuwiki.diary.generateIndex", // :NuwikiDiaryGenerateLinks + "nuwiki.diary.next", // :NuwikiDiaryNextDay + "nuwiki.diary.prev", // :NuwikiDiaryPrevDay + "nuwiki.toc.generate", // :NuwikiTOC + "nuwiki.links.generate", // :NuwikiGenerateLinks + "nuwiki.workspace.checkLinks", // :NuwikiCheckLinks + "nuwiki.tags.search", // :NuwikiSearchTags + "nuwiki.tags.generateLinks", // :NuwikiGenerateTagLinks + "nuwiki.tags.rebuild", // :NuwikiRebuildTags + "nuwiki.export.currentToHtml", // :Nuwiki2HTML + "nuwiki.export.browse", // :Nuwiki2HTMLBrowse + "nuwiki.export.allToHtml", // :NuwikiAll2HTML + "nuwiki.export.rss", // :NuwikiRss + ]; + for cmd in expected { + assert!(COMMANDS.contains(&cmd), "server must advertise {cmd}"); + } +} diff --git a/crates/nuwiki-lsp/tests/commands_files.rs b/crates/nuwiki-lsp/tests/commands_files.rs index ed02df5..8e17720 100644 --- a/crates/nuwiki-lsp/tests/commands_files.rs +++ b/crates/nuwiki-lsp/tests/commands_files.rs @@ -1,6 +1,6 @@ -//! Phase 13: pure-function tests for the rename helper + the file-delete +//! Pure-function tests for the rename helper + the file-delete //! command. The async `Backend::rename` end-to-end (with `read_or_open` -//! hitting disk) is exercised by Phase 14+ integration but covered here +//! hitting disk) is exercised by integration tests but covered here //! at the building-block level — same logic, no async client. use std::path::PathBuf; @@ -95,7 +95,7 @@ async fn file_delete_returns_workspace_edit_with_delete_op() { // // Easier: shape the test as an integration test over the public // module surface — call `commands::execute` once we expose a thin - // builder. For Phase 13 we test the JSON-shaped contract using the + // builder. We test the JSON-shaped contract using the // edits module directly, since that's what `file_delete` returns. let uri = Url::parse("file:///tmp/note.wiki").unwrap(); let mut b = nuwiki_lsp::edits::WorkspaceEditBuilder::new(); diff --git a/crates/nuwiki-lsp/tests/commands_lists.rs b/crates/nuwiki-lsp/tests/commands_lists.rs index 4c2a4c7..e77c205 100644 --- a/crates/nuwiki-lsp/tests/commands_lists.rs +++ b/crates/nuwiki-lsp/tests/commands_lists.rs @@ -1,4 +1,4 @@ -//! §13.1 Cluster A — list rewriters: `removeDone`, `renumber`, +//! Cluster A — list rewriters: `removeDone`, `renumber`, //! `changeSymbol`, `changeLevel`. use nuwiki_core::ast::ListSymbol; @@ -86,10 +86,10 @@ fn remove_done_returns_none_when_no_lists() { } #[test] -fn remove_done_scoped_by_position_to_one_item() { - // With `position` set, only items whose line range contains the - // cursor line are considered. So a done item on line 1 is removed - // by cursor on line 1, but a done item on line 3 is left alone. +fn remove_done_scoped_by_position_to_current_list() { + // With `position` set, the scope is the contiguous list block the + // cursor sits in — every done item in that list is removed, not just + // the one under the cursor. let src = "- [X] done one\n- [ ] todo\n- [X] done two\n"; let ast = parse(src); let pos = Some(LspPosition { @@ -98,7 +98,37 @@ fn remove_done_scoped_by_position_to_one_item() { }); let edit = ops::remove_done_edit(src, &ast, &uri(), pos, true).expect("edit"); let edits = &edit.changes.unwrap()[&uri()]; - assert_eq!(edits.len(), 1, "only the line-0 item should match"); + assert_eq!(edits.len(), 2, "both done items in the current list match"); +} + +#[test] +fn remove_done_position_leaves_other_lists_untouched() { + // Two separate list blocks split by a paragraph. Cursor in the first + // list removes only that list's done items; the second list's done + // item survives. + let src = "- [X] done a\n- [ ] todo a\n\nparagraph\n\n- [ ] todo b\n- [X] done b\n"; + let ast = parse(src); + let pos = Some(LspPosition { + line: 0, + character: 0, + }); + let edit = ops::remove_done_edit(src, &ast, &uri(), pos, true).expect("edit"); + let edits = &edit.changes.unwrap()[&uri()]; + assert_eq!(edits.len(), 1, "only the first list's done item is removed"); +} + +#[test] +fn remove_done_position_cascades_into_sublists() { + // The current-list scope includes nested sublists of the block. + let src = "- [ ] parent\n - [X] done child\n - [ ] open child\n"; + let ast = parse(src); + let pos = Some(LspPosition { + line: 0, + character: 0, + }); + let edit = ops::remove_done_edit(src, &ast, &uri(), pos, true).expect("edit"); + let edits = &edit.changes.unwrap()[&uri()]; + assert_eq!(edits.len(), 1, "the nested done child is removed"); } // ===== renumber ===== diff --git a/crates/nuwiki-lsp/tests/commands_tables.rs b/crates/nuwiki-lsp/tests/commands_tables.rs index 99a88f2..4ac4e40 100644 --- a/crates/nuwiki-lsp/tests/commands_tables.rs +++ b/crates/nuwiki-lsp/tests/commands_tables.rs @@ -1,4 +1,4 @@ -//! §13.1 Cluster B — table rewriters: `insert`, `align`, `moveColumn`. +//! Cluster B — table rewriters: `insert`, `align`, `moveColumn`. use nuwiki_core::syntax::vimwiki::VimwikiSyntax; use nuwiki_core::syntax::SyntaxPlugin; diff --git a/crates/nuwiki-lsp/tests/diary.rs b/crates/nuwiki-lsp/tests/diary.rs index b76888d..ae60cd0 100644 --- a/crates/nuwiki-lsp/tests/diary.rs +++ b/crates/nuwiki-lsp/tests/diary.rs @@ -337,7 +337,7 @@ fn diary_entry_serializes_to_date_string_and_uri() { // ===== COMMANDS list completeness ===== #[test] -fn commands_list_includes_phase16_entries() { +fn commands_list_includes_diary_entries() { let names: Vec<&str> = nuwiki_lsp::commands::COMMANDS.to_vec(); for name in [ "nuwiki.diary.openToday", diff --git a/crates/nuwiki-lsp/tests/folding.rs b/crates/nuwiki-lsp/tests/folding.rs index e6a587e..47b5270 100644 --- a/crates/nuwiki-lsp/tests/folding.rs +++ b/crates/nuwiki-lsp/tests/folding.rs @@ -1,4 +1,4 @@ -//! Phase 19: LSP folding-range provider. +//! LSP folding-range provider. //! //! Drives `folding::folding_ranges` directly. The handler wiring is //! shallow — it just calls into this function — so we verify the diff --git a/crates/nuwiki-lsp/tests/helpers.rs b/crates/nuwiki-lsp/tests/helpers.rs index 043e58d..866e038 100644 --- a/crates/nuwiki-lsp/tests/helpers.rs +++ b/crates/nuwiki-lsp/tests/helpers.rs @@ -1,7 +1,7 @@ //! Tests for the pure helpers that the LSP backend uses to translate //! between `nuwiki-core` ASTs and the LSP wire format. The async -//! request-handling loop is exercised at integration time in Phase 8+ -//! (navigation), so this file stays focused on conversion correctness. +//! request-handling loop is exercised at integration time by the +//! navigation tests, so this file stays focused on conversion correctness. use nuwiki_core::ast::{ inline::InlineNode, BlockNode, BlockquoteNode, DocumentNode, ErrorNode, ParagraphNode, diff --git a/crates/nuwiki-lsp/tests/html_export.rs b/crates/nuwiki-lsp/tests/html_export.rs index b41db6c..ed1a517 100644 --- a/crates/nuwiki-lsp/tests/html_export.rs +++ b/crates/nuwiki-lsp/tests/html_export.rs @@ -1,4 +1,4 @@ -//! Phase 17: HTML export commands. +//! HTML export commands. //! //! Drives the pure `export::*` helpers directly. The side-effecting //! `export_ops::write_page` / `write_rss` paths get exercised via a @@ -474,7 +474,7 @@ fn write_rss_emits_valid_xml_with_entries() { // ===== COMMANDS list completeness ===== #[test] -fn commands_list_includes_phase17_entries() { +fn commands_list_includes_export_entries() { let names: Vec<&str> = nuwiki_lsp::commands::COMMANDS.to_vec(); for name in [ "nuwiki.export.currentToHtml", diff --git a/crates/nuwiki-lsp/tests/link_health.rs b/crates/nuwiki-lsp/tests/link_health.rs index e460fd6..5c24a8d 100644 --- a/crates/nuwiki-lsp/tests/link_health.rs +++ b/crates/nuwiki-lsp/tests/link_health.rs @@ -1,4 +1,4 @@ -//! Phase 15: link-health diagnostics + TOC/links/orphans queries. +//! Link-health diagnostics + TOC/links/orphans queries. use std::path::PathBuf; @@ -630,7 +630,7 @@ fn anchor_matches_unicode_heading() { // ===== COMMANDS completeness ===== #[test] -fn commands_list_includes_phase15_entries() { +fn commands_list_includes_link_health_entries() { let names: Vec<&str> = nuwiki_lsp::commands::COMMANDS.to_vec(); for name in [ "nuwiki.toc.generate", diff --git a/crates/nuwiki-lsp/tests/multi_wiki.rs b/crates/nuwiki-lsp/tests/multi_wiki.rs index 8a0fb3e..51f3a6a 100644 --- a/crates/nuwiki-lsp/tests/multi_wiki.rs +++ b/crates/nuwiki-lsp/tests/multi_wiki.rs @@ -1,8 +1,8 @@ -//! Phase 18: multi-wiki — interwiki resolution + picker commands. +//! Multi-wiki — interwiki resolution + picker commands. //! //! The cross-wiki resolver methods on `Backend` are exercised indirectly //! through `tower_lsp::LspService`'s test harness via a synthetic -//! `Backend` (the same pattern Phase 13 used). To keep this test suite +//! `Backend`. To keep this test suite //! lightweight we drive the *pure* resolution primitives — the parser //! producing `LinkKind::Interwiki` targets, then the `WorkspaceIndex` //! lookup-by-name — and verify the multi-wiki config shape end-to-end. @@ -80,7 +80,7 @@ fn multi_wiki_config_loads_two_entries() { #[test] fn v1_0_single_root_still_works_alongside_multi_wiki_shape() { - // Single-wiki shorthand is the v1.0 form; ensure it still desugars. + // Single-wiki shorthand is the legacy form; ensure it still desugars. let cfg = config_from_json(serde_json::json!({ "wiki_root": "/tmp/legacy", })); @@ -135,7 +135,7 @@ fn nested_root_picks_longest_prefix_match() { // ===== Cross-wiki resolution (uses WorkspaceIndex per wiki) ===== -/// Verify that the cross-wiki resolution semantics match the SPEC: +/// Verify the cross-wiki resolution semantics: /// `[[wiki1:Page]]` looks up `Page` in the *second* wiki's index, not /// the source wiki's. The pure-data check here mirrors what /// `Backend::resolve_target_uri` does. @@ -185,7 +185,7 @@ fn interwiki_resolution_falls_back_when_wiki_missing() { // ===== Wiki commands: COMMANDS list completeness ===== #[test] -fn commands_list_includes_phase18_entries() { +fn commands_list_includes_wiki_entries() { let names: Vec<&str> = nuwiki_lsp::commands::COMMANDS.to_vec(); for name in [ "nuwiki.wiki.listAll", diff --git a/crates/nuwiki-lsp/tests/navigation.rs b/crates/nuwiki-lsp/tests/navigation.rs index 5cf0ff3..e5ea3e8 100644 --- a/crates/nuwiki-lsp/tests/navigation.rs +++ b/crates/nuwiki-lsp/tests/navigation.rs @@ -1,7 +1,7 @@ -//! Tests for the Phase 8 navigation pieces: WorkspaceIndex queries, +//! Tests for the navigation pieces: WorkspaceIndex queries, //! page-name derivation, anchor slugify, position lookup, and link //! resolution. The async LSP handlers are exercised through these -//! helpers; end-to-end JSON-RPC drive lives in a follow-up phase. +//! helpers; end-to-end JSON-RPC drive lives elsewhere. use std::path::PathBuf; diff --git a/development/ONBOARDING.md b/development/ONBOARDING.md new file mode 100644 index 0000000..c9370cb --- /dev/null +++ b/development/ONBOARDING.md @@ -0,0 +1,249 @@ +# nuwiki Developer Onboarding + +## Overview + +nuwiki is a vimwiki-compatible Vim/Neovim plugin backed by a Rust language server. It provides full vimwiki syntax support while keeping the original file format, keymaps, and command surface intact. The substantive work happens in a Rust LSP daemon — Vim and Neovim are thin client layers that wire up keystrokes and display results. + +## Repository Structure + +``` +nuwiki/ +├── Cargo.toml # Rust workspace root +├── Cargo.lock +├── crates/ +│ ├── nuwiki-ls/ # Binary crate — thin main.rs, starts stdio LSP server +│ │ ├── Cargo.toml +│ │ └── src/main.rs +│ │ +│ ├── nuwiki-core/ # Library crate — parser, AST, renderer (no editor deps) +│ │ ├── Cargo.toml +│ │ └── src/ +│ │ ├── lib.rs +│ │ ├── syntax/ # Syntax plugins (vimwiki, future markdown) +│ │ │ ├── mod.rs +│ │ │ ├── registry.rs +│ │ │ └── vimwiki/ +│ │ │ ├── mod.rs +│ │ │ ├── lexer.rs +│ │ │ └── parser.rs +│ │ ├── ast/ # Abstract Syntax Tree node definitions +│ │ │ ├── mod.rs +│ │ │ ├── block.rs +│ │ │ ├── inline.rs +│ │ │ └── link.rs +│ │ └── render/ # Renderers (HTML, etc.) +│ │ ├── mod.rs +│ │ └── html.rs +│ │ +│ └── nuwiki-lsp/ # Library crate — LSP protocol bridge +│ ├── Cargo.toml +│ └── src/lib.rs +│ +├── plugin/ # Universal Vim/Neovim entry point +│ └── nuwiki.vim +│ +├── lua/ # Neovim-specific Lua layer +│ └── nuwiki/ +│ ├── init.lua +│ ├── config.lua +│ ├── lsp.lua +│ └── install.lua # Binary download / build-from-source logic +│ +├── autoload/ # Lazy-loaded VimL (Vim compat layer) +│ └── nuwiki/ +│ └── lsp.vim +│ +├── ftdetect/ # Filetype detection for .wiki files +│ └── nuwiki.vim +│ +├── ftplugin/ # Per-filetype buffer settings +│ └── nuwiki.vim +│ +├── syntax/ # Static fallback syntax highlighting (no LSP required) +│ └── nuwiki.vim +│ +├── doc/ # Vim help documentation +│ └── nuwiki.txt +│ +├── scripts/ # Plugin-runtime scripts only +│ └── download_bin.vim # VimL binary download (used by Dein/vim-plug build hooks) +│ +├── development/ # Developer-only tooling and docs (not shipped) +│ ├── ONBOARDING.md # This file +│ ├── SPEC.md # Technical reference (architecture, LSP, commands) +│ ├── nuwiki-architecture.html +│ ├── start-nvim.sh # Launch Neovim against a generated sample wiki +│ ├── start-vim.sh # Launch Vim against a generated sample wiki +│ ├── syntax-diag.vim # Dump highlighting state for debugging +│ └── tests/ # Editor keymap/command harnesses (run in CI) +│ ├── test-keymaps.sh # Neovim keymap harness +│ ├── test-keymaps.lua +│ ├── test-keymaps-vim.sh # Vim keymap harness +│ └── test-keymaps-vim.vim +│ +└── .gitea/ + └── workflows/ + ├── ci.yaml # Lint, test, fmt check on every push/PR + └── release.yaml # Cross-compile + release on v* tag +``` + +## Key Crates + +1. **nuwiki-core**: Contains the parser, lexer, AST, and renderer. This is the pure-Rust core with no editor dependencies. +2. **nuwiki-lsp**: Bridges the core to the LSP protocol using tower-lsp. Handles LSP requests/responses and manages the document store. +3. **nuwiki-ls**: Binary crate that starts the stdio LSP server. Very thin — just initializes the LSP server and runs it. + +## Build & Installation + +### Prerequisites +- Rust toolchain (1.83+ stable) +- Cargo +- For editor integration: Vim 9+ or Neovim 0.5+ with an LSP client (vim-lsp recommended for Vim, built-in for Neovim 0.11+) + +### Development Build +```bash +# From the repository root +cargo build --release -p nuwiki-ls # Builds the LSP binary +``` + +The binary will be placed at `target/release/nuwiki-ls`. + +### Installation via Plugin Managers + +#### lazy.nvim +```lua +{ + 'gffranco/nuwiki', + build = ":lua require('nuwiki').install()", + ft = { 'vimwiki' }, + opts = { + wiki_root = '~/vimwiki', + }, +} +``` + +#### vim-plug +```vim +Plug 'gffranco/nuwiki', { 'do': 'vim -e -s -c \"source scripts/download_bin.vim\" -c \"q\"' } +``` + +#### Dein +```vim +call dein#add('gffranco/nuwiki', { +\ 'build': 'vim -e -s -c \"source scripts/download_bin.vim\" -c \"q\"' +\ }) +``` + +### Manual Installation (Plain Vim) +```bash +git clone https://code.gfran.co/gffranco/nuwiki ~/.vim/pack/gffranco/start/nuwiki +cd ~/.vim/pack/gffranco/start/nuwiki +cargo build --release -p nuwiki-ls +mkdir -p bin && ln -s ../target/release/nuwiki-ls bin/nuwiki-ls +``` + +Plain Vim users also need an LSP client — vim-lsp is recommended. + +## Development Workflow + +### Testing +```bash +# Run all tests (workspace) +cargo test --workspace + +# Run tests for a specific crate +cargo test -p nuwiki-core +cargo test -p nuwiki-lsp +cargo test -p nuwiki-ls +``` + +### Linting & Formatting +```bash +# Check formatting +cargo fmt -- --check + +# Run Clippy +cargo clippy --workspace -- -D warnings +``` + +### Running the LSP Server Manually (for debugging) +```bash +# Build and run the binary +cargo run -p nuwiki-ls -- --help +``` + +The LSP server communicates over stdio. You can test it with an LSP client like `lspci` or by connecting from Neovim. + +### Health Check (Neovim) +After installing the plugin, run: +``` +:checkhealth nuwiki +``` +This verifies: +- Binary exists at `{plugin_dir}/bin/nuwiki-ls` +- Binary is executable and responds to `--version` +- LSP client is running +- Filetype detection works for `.wiki` files + +## Editor Layers + +### VimL Layer (`plugin/nuwiki.vim`, `autoload/`) +- Universal entry point for all plugin managers +- Detects whether running in Neovim or Vim +- For Neovim: delegates to Lua layer +- For Vim: starts the LSP server via `nuwiki#lsp#start()` + +### Neovim Lua Layer (`lua/nuwiki/`) +- `init.lua`: Main setup function +- `config.lua`: Configuration schema and defaults +- `lsp.lua`: Starts the LSP client using `vim.lsp.start()` +- `install.lua`: Handles binary download/build and places it in the plugin's `bin/` directory + +### Vim Compatibility Layer (`autoload/nuwiki/lsp.vim`) +- Provides the `nuwiki#lsp#start()` function for Vim +- Handles binary location and LSP client startup via vim-lsp or coc.nvim + +## Important Notes + +- The core library (`nuwiki-core`) is completely editor-agnostic and can be tested independently. +- The LSP layer (`nuwiki-lsp`) depends only on the core and tower-lsp. +- The binary crate (`nuwiki-ls`) is intentionally thin — it just initializes and runs the LSP server. +- Editor layers (VimL/Lua) contain zero logic — they are pure wiring only. +- All syntax-specific code lives in `nuwiki-core/src/syntax//` making it easy to add new syntaxes (e.g., markdown) in the future. +- The project uses a workspace Cargo.toml with resolver v2 (edition 2021). + +## Getting Started + +1. Clone the repository: + ```bash + git clone https://code.gfran.co/gffranco/nuwiki + cd nuwiki + ``` + +2. Build the LSP binary: + ```bash + cargo build --release -p nuwiki-ls + ``` + +3. Set up a test wiki directory: + ```bash + mkdir -p ~/test-wiki + echo "# Test Wiki" > ~/test-wiki/index.wiki + ``` + +4. Install the plugin in your Neovim/Vim configuration using your preferred plugin manager (see above examples). + +5. Open a .wiki file and verify: + - Syntax highlighting works + - `:VimwikiIndex` opens the index page + - LSP features (go-to-definition, hover, completions) work via `:checkhealth nuwiki` + +## CI/CD + +The project uses Gitea Actions: +- CI (`.gitea/workflows/ci.yaml`): Runs on every push/PR — checks formatting, Clippy, and runs tests. +- Release (`.gitea/workflows/release.yaml`): Triggers on `v*` tag — cross-compiles for Linux targets and creates a Gitea release. + +## License + +Dual-licensed under MIT or Apache-2.0 at your option. \ No newline at end of file diff --git a/development/SPEC.md b/development/SPEC.md new file mode 100644 index 0000000..15f52cc --- /dev/null +++ b/development/SPEC.md @@ -0,0 +1,339 @@ +# nuwiki — Technical Reference + +A developer-facing description of how nuwiki is built and what it implements. +For day-one setup see [ONBOARDING.md](ONBOARDING.md); for user-facing help see +`doc/nuwiki.txt` (`:h nuwiki`). + +nuwiki is a vimwiki-compatible Vim/Neovim plugin backed by a Rust language +server. The editor layers are thin clients; all parsing, navigation, and +editing logic lives in the Rust crates and is reached over LSP. + +| Property | Value | +|---|---| +| License | Dual MIT / Apache-2.0 | +| MSRV | Rust 1.83 | +| Edition / resolver | 2021 / v2 | +| Repo | `https://code.gfran.co/gffranco/nuwiki` | +| VCS / CI | Gitea + Gitea Actions | +| LSP library | `tower-lsp` (tokio, stdio transport) | +| Min editors | Neovim 0.11+, Vim 9.1+ | + +--- + +## 1. Architecture + +### Layer model + +``` +Editor client (VimL / Lua) — pure wiring, no logic + │ LSP over stdio + ▼ +nuwiki-lsp — protocol bridge, executeCommand, document store, config + │ + ▼ +nuwiki-core — lexer → parser → AST → renderer (no editor deps) +``` + +### Crate dependency rules + +``` +nuwiki-ls → nuwiki-lsp → nuwiki-core +``` + +- `nuwiki-core` never depends on `nuwiki-lsp` / `nuwiki-ls`. +- `nuwiki-lsp` never depends on `nuwiki-ls`. +- The VimL and Lua layers contain no logic — every command body issues a + `workspace/executeCommand` or a built-in LSP request. + +### Repository layout + +``` +crates/ + nuwiki-core/ # parser, AST, renderer — editor-independent + src/ + ast/ # block.rs inline.rs link.rs span.rs visit.rs + syntax/ # registry.rs + vimwiki/{lexer,parser}.rs + render/ # html.rs + date.rs # diary period math (no chrono dependency) + nuwiki-lsp/ # LSP backend + src/ + lib.rs # Backend, capabilities, textDocument handlers + commands.rs # executeCommand dispatcher + pure edit ops + config.rs # Config / WikiConfig / HtmlConfig + diagnostics.rs index.rs nav.rs rename.rs semantic_tokens.rs + diary.rs export.rs edits.rs folding.rs wiki.rs + nuwiki-ls/ # thin binary: starts the stdio server + +plugin/nuwiki.vim # universal entry point (detects Vim vs Neovim) +lua/nuwiki/ # Neovim layer: init, config, lsp, keymaps, commands, + # folding, install +autoload/nuwiki/ # Vim layer: lsp.vim, commands.vim +ftdetect/ ftplugin/ syntax/ # filetype detection, buffer setup, fallback HL +doc/nuwiki.txt # :help +scripts/download_bin.vim # plugin-runtime binary download (build hooks) +development/ # dev-only tooling + this document (not shipped) +.gitea/workflows/ # ci.yaml, release.yaml +``` + +--- + +## 2. nuwiki-core + +### AST + +Every node carries a `Span { start, end }` of `Position { line, column, offset }` +(0-indexed; column and offset are byte-based). Spans drive diagnostics, +semantic tokens, and go-to-definition. + +**Block nodes:** `Heading` (level 1–6, `centered`), `Paragraph`, +`HorizontalRule`, `Blockquote` (recursive), `Preformatted` (optional +language), `MathBlock` (optional environment), `List`, `DefinitionList`, +`Table`, `Comment`, `Tag`, `Error` (resilient parse failure). + +**Inline nodes:** `Text`, `Bold`, `Italic`, `BoldItalic`, `Strikethrough`, +`Code`, `Superscript`, `Subscript`, `MathInline`, `Keyword`, `Color`, +`WikiLink`, `ExternalLink`, `Transclusion`, `RawUrl`. + +**Enums:** +- `ListSymbol` = `Dash | Star | Hash | Numeric | NumericParen | AlphaParen | + AlphaUpperParen | RomanParen | RomanUpperParen` +- `CheckboxState` = `Empty | Quarter | Half | ThreeQuarters | Done | Rejected` +- `Keyword` = `Todo | Done | Started | Fixme | Fixed | Xxx | Stopped` +- `LinkKind` = `Wiki | Interwiki | Diary | File | Local | Raw | AnchorOnly` +- `TagScope` = `File | Heading(idx) | Standalone` + +`PageMetadata` carries `title`, `nohtml`, `template`, `date`, and aggregated +file-level `tags`. + +### Lexer / parser + +- Two-pass lexer (block pass, then inline pass per block); syntax-specific + `VimwikiToken`s with byte-offset spans, collected eagerly into a + `TokenStream`. +- Hand-rolled recursive-descent parser (not a combinator library). Resilient: + malformed input yields an `ErrorNode` and parsing continues — a document + never fails to parse. +- Full re-parse on every `didOpen` / `didChange` (no incremental parsing). + +### Syntax plugin interface + +Syntaxes register against a `SyntaxRegistry` keyed by id and file extension: + +``` +SyntaxPlugin { id, display_name, file_extensions, lexer, parser } +``` + +vimwiki (`.wiki`) is the only registered syntax; the registry exists so a +markdown plugin can be added without touching the LSP or editor layers. + +### Renderer + +`Renderer` trait writes to a `dyn Write`; `HtmlRenderer` is the implementation. +Link resolution is injected as a callback. Template substitution supports +`{{title}}`, `{{content}}`, `{{date}}`, `{{root_path}}`, `{{toc}}`, `{{css}}`. +`color_dic` maps colour-tag names to CSS values, falling back to +`class="color-"`. + +--- + +## 3. LSP server + +### Advertised capabilities + +| Capability | Notes | +|---|---| +| `textDocumentSync` | full sync (open/close/change/save) | +| `semanticTokensProvider` | full + range; custom vimwiki token legend | +| `documentSymbolProvider` / `workspaceSymbolProvider` | outline + workspace search (tags included) | +| `definitionProvider` / `referencesProvider` | link follow + backlinks | +| `hoverProvider` | link preview | +| `completionProvider` | trigger `[` | +| `renameProvider` | cross-document link rewrite | +| `foldingRangeProvider` | heading blocks + top-level lists | +| `executeCommandProvider` | see command surface below | +| `workspace.fileOperations` | `did_rename` + `did_delete` for `**/*.wiki`, `**/*.md` | +| `positionEncoding` | UTF-8 when client supports LSP 3.17+, else UTF-16 | + +Workspace indexing runs as a background tokio task reporting via +`window/workDoneProgress`; nav features answer with partial data until the +scan completes. The document store is an `Arc>` +holding text + AST + version. + +### executeCommand surface + +All editing commands return a `WorkspaceEdit` (applied via +`workspace/applyEdit`) built through `edits::WorkspaceEditBuilder`, so the AST +stays the source of truth. Navigation commands return a `Location`. + +| Namespace | Commands | +|---|---| +| `list` | `toggleCheckbox` `cycleCheckbox` `rejectCheckbox` `nextTask` `removeDone` `renumber` `changeSymbol` `changeLevel` | +| `table` | `insert` `align` `moveColumn` | +| `heading` | `addLevel` `removeLevel` | +| `link` | `pasteWikilink` `pasteUrl` | +| `toc` / `links` | `toc.generate` · `links.generate` | +| `workspace` | `checkLinks` `findOrphans` | +| `diary` | `openToday` `openYesterday` `openTomorrow` `openIndex` `generateIndex` `next` `prev` `listEntries` `openForDate` | +| `tags` | `search` `generateLinks` `rebuild` | +| `export` | `currentToHtml` `allToHtml` `allToHtmlForce` `browse` `rss` | +| `wiki` | `listAll` `select` `openIndex` `tabOpenIndex` `gotoPage` | +| `file` | `delete` | + +`workspace/rename` emits a `RenameFile` op plus `[[A]]` → `[[B]]` rewrites +across every linking page (anchors and descriptions preserved). Closed +documents are re-parsed on demand so cross-document edits use current source. + +### Diagnostics + +Composable collector chains parse errors (`ErrorNode` walk) and broken-link +checks (wiki target / anchor / `file:` / `local:` existence). Per-source +severity is config-driven (`diagnostic.link_severity`: `off|hint|warn|error`); +raw and external URLs are never diagnosed. + +--- + +## 4. Editor integration + +### Installation + +Pre-built binaries are published as Gitea release assets +(`nuwiki-ls-{version}-{target}.tar.gz`) and fetched at install time by +`lua/nuwiki/install.lua` (Neovim) or `scripts/download_bin.vim` (Vim build +hooks), installed to `{plugin_dir}/bin/nuwiki-ls`. Falls back to +`cargo build --release` when download fails or +`g:nuwiki_build_from_source = 1`. + +`plugin/nuwiki.vim` dispatches: Neovim → `require('nuwiki').setup()`; Vim → +`nuwiki#lsp#start()` (prefers `vim-lsp`, falls back to `coc.nvim`). On Neovim +the server is registered via `vim.lsp.config{}` + `vim.lsp.enable`, with a +`vim.lsp.start` autocmd fallback. + +### Configuration + +Single-wiki (v1.0) and multi-wiki forms both work; the scalar form desugars to +a one-entry `wikis` list. + +```lua +require('nuwiki').setup({ + -- single-wiki shorthand + wiki_root = '~/vimwiki', file_extension = '.wiki', syntax = 'vimwiki', + log_level = 'warn', + + -- or multi-wiki + wikis = { + { name = 'personal', root = '~/vimwiki', diary_rel_path = 'diary', + diary_frequency = 'daily', html = { html_path = '~/vimwiki/_html' } }, + }, + + diagnostic = { link_severity = 'warn' }, + mappings = { -- all default true except mouse + enabled = true, wiki_prefix = true, links = true, lists = true, + headers = true, table_editing = true, diary = true, html_export = true, + text_objects = true, mouse = false, + }, + folding = 'lsp', -- lsp | expr | off +}) +``` + +Per-wiki keys include `index`, `diary_rel_path`/`diary_index`/ +`diary_frequency`/`diary_start_week_day`/`diary_sort`/`diary_caption_level`/ +`diary_header`, `listsyms`/`listsyms_propagate`/`list_margin`, +`links_space_char`, `nested_syntaxes`, `auto_toc`, `maxhi`, and an `html` +table (`html_path`, `template_path`/`template_default`/`template_ext`/ +`template_date_format`, `css_name`, `auto_export`, +`html_filename_parameterization`, `exclude_files`, `color_dic`). + +### Command surface + +`ftplugin/vimwiki.vim` (Vim) and `lua/nuwiki/commands.lua` (Neovim) define the +vimwiki command set with `:Vimwiki*` names plus `:Nuwiki*` aliases — index/tab +index/UI select, diary (note/yesterday/tomorrow/next/prev/index/generate), +link follow/backlinks/next/prev, goto/delete/rename, list toggle/reject/ +remove-done, TOC, generate/check links, find orphans, tags rebuild/search/ +generate, HTML 2HTML/browse/all/rss, table insert/move/colorize, and paste +link/url. Search uses `lvimgrep` and backlinks/follow use built-in LSP +requests; the rest route to `executeCommand`. + +### Default keymaps + +Buffer-local, gated per subgroup by `mappings.*`: + +- **Wiki** `ww`/`wt`/`ws`/`wi`, `ww|y|t|m|i`, + `wn`/`wd`/`wr`/`wc` +- **Links** `` (follow / wrap-word), ``/``/`` + (split/vsplit/tab), `` (back), ``/`` (next/prev link), `+` + (wrap as wikilink) +- **Lists** `` (toggle), `gnt` (next task), `gln`/`glp` (cycle + forward/back), `glx` (reject), `gll`/`glh` + `gLl`/`gLh` (indent/dedent, + item vs subtree), `glr`/`gLr` (renumber list/all), `gl`/`gL` + (remove done, list/whole-doc), `o`/`O` (open with bullet); insert-mode + ``/``, ``/``, ``; smart ``/`` +- **Headers** `=`/`-` (deeper/shallower), `]]`/`[[` (next/prev), `]=`/`[=` + (sibling), `]u`/`[u` (parent) — pure VimL/Lua, no LSP round-trip +- **Tables** `gqq`/`gww` (align), ``/`` (move column) +- **Diary** ``/`` (next/prev entry) +- **HTML** `wh`/`whh`/`wha` (export/browse/all) +- **Mouse** (opt-in) `<2-LeftMouse>` follow + shift/ctrl variants + +**Text objects:** `ah`/`ih` (heading section), `aH`/`iH` (heading + subtree), +`al`/`il` (list item), `a\`/`i\` (table cell), `ac`/`ic` (table column). Pure +VimL/Lua. + +### Folding + +Primary: LSP `textDocument/foldingRange` (`folding::folding_ranges`, heading +blocks + top-level lists). Fallback: a pure Lua `foldexpr` in +`lua/nuwiki/folding.lua` for clients without `foldingRange`. + +### Health check + +`:checkhealth nuwiki` verifies the binary exists and responds to `--version`, +the LSP client is attached, `.wiki` filetype detection works, and reports +registered commands and per-wiki index state. + +--- + +## 5. Vimwiki syntax support + +Parsed and highlighted (custom semantic-token legend; `syntax/nuwiki.vim` +provides a no-LSP fallback): + +- **Typefaces** bold `*…*`, italic `_…_`, bold-italic, strikethrough `~~…~~`, + inline code, super `^…^` / sub `,,…,,`, keywords (TODO/DONE/STARTED/FIXME/ + FIXED/XXX/STOPPED) +- **Links** plain/described/subdir/root/absolute wikilinks, `[[dir/]]`, + interwiki (`wiki1:` / `wn.Name:`), diary, anchor-only and `Page#anchor`, + raw URLs, `file:`/`local:`, transclusion `{{url|alt|attrs}}` +- **Headers** levels 1–6, centered +- **Lists** unordered (`-` `*` `#`), ordered (`1.` `1)` `a)` `A)` `i)` `I)`), + nested/mixed, multi-line items, definition lists, all six checkbox states +- **Tables** `|`-delimited, header separator, column alignment markers, + colspan `>` / rowspan `\/`, inline formatting in cells +- **Blocks** preformatted `{{{ … }}}` (optional language), inline math `$…$`, + block math `{{$ … }}$` (optional environment), blockquotes, comments + (`%% …`), horizontal rule `----` +- **Tags** `:tag1:tag2:` (file / heading / standalone scope), tag-as-anchor +- **Placeholders** `%title` `%nohtml` `%template` `%date` + +--- + +## 6. CI/CD + +`.gitea/workflows/ci.yaml` runs on every push and PR: + +| Job | Command | +|---|---| +| fmt | `cargo fmt --all -- --check` | +| clippy | `cargo clippy --workspace --all-targets -- -D warnings` | +| test | `cargo test --workspace --all-targets` | +| keymaps | `development/tests/test-keymaps.sh` (Neovim 0.11) + `test-keymaps-vim.sh` (Vim) | + +`.gitea/workflows/release.yaml` triggers on `v*` tags: cross-compiles the four +Linux targets (gnu/musl × x86_64/aarch64) via `cross`, packages `.tar.gz`, and +creates a Gitea release using `RELEASE_TOKEN`. macOS and Windows binaries are +built manually and attached to the same release. crates.io publishing is not +wired up. + +A change is semver-breaking if it alters `nuwiki-core` AST nodes, the +`SyntaxPlugin`/`Renderer` traits, the user-config schema, or removes an LSP +capability. diff --git a/development/_common.sh b/development/_common.sh new file mode 100644 index 0000000..800e053 --- /dev/null +++ b/development/_common.sh @@ -0,0 +1,276 @@ +#!/usr/bin/env bash +# +# development/_common.sh — shared helpers for the start-* dev launchers. +# +# Sourced by start-nvim.sh and start-vim.sh (not run directly). Owns the +# pieces both launchers share so they stay in lockstep — most importantly +# the sample-wiki fixture, which previously had to be edited in two +# places. +# +# Exports: +# REPO_ROOT / DEV_DIR / WIKI_DIR — common paths +# log — prefixed status line +# ensure_binary — build + symlink nuwiki-ls +# write_sample_wiki DIR — lay down the feature-showcase wiki +# seed_wiki — seed WIKI_DIR (respects NUWIKI_DEV_NO_SEED) + +# Resolve paths relative to this file so it works regardless of which +# launcher sources it. +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +DEV_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/nuwiki-dev" +WIKI_DIR="${NUWIKI_DEV_WIKI:-$DEV_DIR/wiki}" + +log() { printf '\033[1;34m[nuwiki-dev]\033[0m %s\n' "$*"; } + +ensure_binary() { + local bin="$REPO_ROOT/bin/nuwiki-ls" + local built="$REPO_ROOT/target/release/nuwiki-ls" + if [[ "${NUWIKI_DEV_SKIP_BUILD:-0}" == "1" ]]; then + if [[ ! -x "$bin" ]]; then + log "NUWIKI_DEV_SKIP_BUILD=1 but $bin is missing — building anyway" + else + log "NUWIKI_DEV_SKIP_BUILD=1 — using existing $bin" + return + fi + fi + log "building nuwiki-ls (release)…" + cargo build --release -p nuwiki-ls --manifest-path "$REPO_ROOT/Cargo.toml" + mkdir -p "$REPO_ROOT/bin" + ln -sf "$built" "$bin" + log "binary ready: $bin (built $(date -r "$built" '+%Y-%m-%d %H:%M:%S'))" +} + +# write_sample_wiki DIR — lay down a fixture that exercises every vimwiki +# feature nuwiki supports: one page per feature group plus a diary. +# Heredocs use a quoted delimiter so `$math`, backticks, and backslashes +# land verbatim. +write_sample_wiki() { + local root="$1" + mkdir -p "$root" "$root/diary" + + cat > "$root/index.wiki" <<'EOF' += nuwiki sample wiki = + +%% Generated by the start-* dev launchers. A single fixture that exercises +%% every vimwiki feature nuwiki supports — open the linked pages and run +%% the plugin commands against them. + +One page per feature group: + +- [[Syntax]] — inline formatting, blocks, comments +- [[Lists]] — bullets, ordering, checkbox states, nesting +- [[Tables]] — alignment and spanning cells +- [[Links]] — every wikilink and URL form +- [[Notes]] — anchor targets for cross-page links +- [[diary/diary]] — the diary index + +=== Heading level 3 === +==== Heading level 4 ==== +===== Heading level 5 ===== +====== Heading level 6 ====== + +== Commands to try == + +- :NuwikiTOC +- :NuwikiGenerateLinks +- :NuwikiCheckLinks +- :NuwikiMakeDiaryNote + +:sample:smoke-test:sandbox: +EOF + + cat > "$root/Syntax.wiki" <<'EOF' += Syntax showcase = + +Back to [[index]]. + +== Inline formatting == + +*bold*, _italic_, *_bold italic_*, ~~strikethrough~~, `inline code`, +super^script^ and sub,,script,,, and inline math $e^{i\pi} + 1 = 0$. + +== Keywords == + +TODO STARTED FIXME XXX DONE FIXED STOPPED + +== Horizontal rule == + +---- + +== Blockquote == + +> A quoted line. +> A second quoted line. + +== Definition list == + +Term:: Definition of the term. +Another:: Its definition. + +== Code block == + +{{{python +def greet(name): + return f"hello {name}" +}}} + +== Math block == + +{{$ +\sum_{i=1}^{n} i = \frac{n(n+1)}{2} +}}$ + +== Comments == + +%% a single-line comment +%%+ a multi-line +comment block +%% + +== Transclusion == + +{{file:image.png}} +{{image.png|alt text|width=120}} +EOF + + cat > "$root/Lists.wiki" <<'EOF' += Lists = + +Back to [[index]]. + +== Unordered == + +- item with dash +- second + - nested under dash +* item with star +# item with hash + +== Ordered == + +1. first +2. second + 1. nested ordered +1) paren style +a) alpha +A) Alpha +i) roman +I) Roman + +== Checkbox states == + +- [ ] empty (0%) +- [.] started (1-33%) +- [o] in progress (34-66%) +- [O] nearly done (67-99%) +- [X] done (100%) +- [-] rejected + +== Nested with checkboxes == + +- [ ] parent task + - [X] done subtask + - [ ] pending subtask + - [ ] deep subtask +EOF + + cat > "$root/Tables.wiki" <<'EOF' += Tables = + +Back to [[index]]. + +== Plain == + +| Name | Role | +| Alice | Author | +| Bob | Editor | + +== Aligned == + +| Left | Center | Right | +|:-----|:------:|------:| +| a | b | c | +| d | e | f | + +== Spanning cells == + +| Header | Span | +| cell | > | +| rowspan | value | +| \/ | value | +EOF + + cat > "$root/Links.wiki" <<'EOF' += Links = + +Back to [[index]]. + +== Wikilinks == + +- [[Notes]] +- [[Notes|described]] +- [[Notes#Section one]] +- [[Notes#Section one|anchored + described]] +- [[/index]] +- [[diary/]] +- [[#Wikilinks]] + +== Diary and interwiki == + +- [[diary:2026-05-30]] +- [[wiki1:index]] +- [[wn.MyWiki:index]] + +== External and raw URLs == + +- [[https://example.com]] +- [[https://example.com|Example]] +- https://example.com +- mailto:gffranco@gmail.com +- file:///etc/hosts +EOF + + cat > "$root/Notes.wiki" <<'EOF' += Notes = + +A page that other pages point at. Back to [[index]]. + +== Section one == + +Anchor target for cross-page links. + +== Section two == + +Another anchor target. +EOF + + cat > "$root/diary/diary.wiki" <<'EOF' += Diary = + +Back to [[/index]]. + +- [[2026-05-30]] +EOF + + cat > "$root/diary/2026-05-30.wiki" <<'EOF' += 2026-05-30 = + +A diary entry. Back to [[diary]]. + +- [X] set up sample wiki +- [ ] review every feature page +EOF +} + +seed_wiki() { + if [[ "${NUWIKI_DEV_NO_SEED:-0}" == "1" ]]; then + log "NUWIKI_DEV_NO_SEED=1 — skipping wiki seed (using $WIKI_DIR as-is)" + return + fi + mkdir -p "$WIKI_DIR" "$WIKI_DIR/diary" + # Seed the whole showcase as a unit; skip when an index already exists + # so a re-run never clobbers edits made while testing. + if [[ -f "$WIKI_DIR/index.wiki" ]]; then + return + fi + write_sample_wiki "$WIKI_DIR" +} diff --git a/development/nuwiki-architecture.html b/development/nuwiki-architecture.html new file mode 100644 index 0000000..6bfe702 --- /dev/null +++ b/development/nuwiki-architecture.html @@ -0,0 +1,324 @@ + + + + + + nuwiki Architecture Diagram + + + + +
+ +
+
+
+

nuwiki Architecture

+
+

Layered architecture showing the Rust LSP backend and editor integration layers

+
+ + +
+ + + + + + + + + + + + + + + + + + Consumer Layer + Editor highlight, HTML export, TOC, etc. + + + + AST Layer (nuwiki-core) + Document > Block nodes > Inline nodes + + + + Parser Layer + Syntax-specific (nuwiki-core) + + + + Lexer Layer + Syntax-specific (nuwiki-core) + + + + Raw Text Input + + + + + + + + + + + nuwiki-ls + Binary crate — starts stdio LSP server + + + + nuwiki-lsp + LSP protocol bridge (tower-lsp) + + + + nuwiki-core + Parser, AST, Renderer (editor-agnostic) + + + + + + + depends on + depends on + + + + + VimL Layer + plugin/nuwiki.vim, autoload/ + + + + Lua Layer + lua/nuwiki/ + + + + + + + Vim/Neovim + Neovim only + + + + LSP Features + • textDocument/semanticTokens + • textDocument/publishDiagnostics + • textDocument/definition + • textDocument/references + • textDocument/documentSymbol + • textDocument/hover + • textDocument/completion + • workspace/rename + • workspace/symbol + + + + + + + Crate Dependencies + + + Legend + + + Editor Layers + + + Core Crates + + + Layer Boundaries + + + Data Flow + +
+ + +
+
+
+
+

Editor Integration

+
+
    +
  • • VimL layer for universal Vim support
  • +
  • • Lua layer for Neovim integration
  • +
  • • Zero-logic editor layers (pure wiring only)
  • +
  • • Supports vim-lsp and coc.nvim for Vim
  • +
  • • Built-in LSP client for Neovim 0.11+
  • +
+
+ +
+
+
+

Core Architecture

+
+
    +
  • • Strict layer separation (no circular deps)
  • +
  • • nuwiki-core is editor-agnostic
  • +
  • • LSP bridge uses tower-lsp
  • +
  • • Binary crate is intentionally thin
  • +
  • • Strict dependency rules enforced
  • +
+
+ +
+
+
+

LSP Features

+
+
    +
  • • Syntax highlighting (semantic tokens)
  • +
  • • Diagnostics (broken links, parse errors)
  • +
  • • Go-to-definition and references
  • +
  • • Document symbols (TOC/outline)
  • +
  • • Hover, completion, workspace symbols
  • +
  • • Rename and workspace-wide operations
  • +
+
+
+ + + +
+ + \ No newline at end of file diff --git a/start-nvim.sh b/development/start-nvim.sh similarity index 54% rename from start-nvim.sh rename to development/start-nvim.sh index be143ac..88e3e7c 100755 --- a/start-nvim.sh +++ b/development/start-nvim.sh @@ -10,11 +10,12 @@ # $XDG_CACHE_HOME/nuwiki-dev/wiki # * calls `require('nuwiki').setup({...})` # -# Usage: ./start-nvim.sh [extra args...] -# ./start-nvim.sh # open the scratch wiki's index -# ./start-nvim.sh path/to/note.wiki # open a specific file -# NUWIKI_DEV_WIKI=/tmp/foo ./start-nvim.sh -# NUWIKI_DEV_SKIP_BUILD=1 ./start-nvim.sh # reuse the cached binary +# Usage: ./development/start-nvim.sh [extra args...] +# ./development/start-nvim.sh # open the scratch wiki's index +# ./development/start-nvim.sh path/to/note.wiki # open a specific file +# NUWIKI_DEV_WIKI=/tmp/foo ./development/start-nvim.sh +# NUWIKI_DEV_NO_SEED=1 ./development/start-nvim.sh # use WIKI_DIR as-is (no scratch seeding) +# NUWIKI_DEV_SKIP_BUILD=1 ./development/start-nvim.sh # reuse the cached binary # # The release binary is rebuilt on every launch so source changes # always reach the running LSP. `cargo build --release` is incremental, @@ -27,82 +28,14 @@ set -euo pipefail -REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -DEV_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/nuwiki-dev" -WIKI_DIR="${NUWIKI_DEV_WIKI:-$DEV_DIR/wiki}" +# Shared paths + helpers (log, ensure_binary, write_sample_wiki, seed_wiki). +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/_common.sh" + INIT_FILE="$DEV_DIR/init.lua" STATE_DIR="$DEV_DIR/nvim-state" DATA_DIR="$DEV_DIR/nvim-data" CONFIG_DIR="$DEV_DIR/nvim-config" -log() { printf '\033[1;34m[nuwiki-dev]\033[0m %s\n' "$*"; } - -ensure_binary() { - local bin="$REPO_ROOT/bin/nuwiki-ls" - local built="$REPO_ROOT/target/release/nuwiki-ls" - if [[ "${NUWIKI_DEV_SKIP_BUILD:-0}" == "1" ]]; then - if [[ ! -x "$bin" ]]; then - log "NUWIKI_DEV_SKIP_BUILD=1 but $bin is missing — building anyway" - else - log "NUWIKI_DEV_SKIP_BUILD=1 — using existing $bin" - return - fi - fi - log "building nuwiki-ls (release)…" - cargo build --release -p nuwiki-ls --manifest-path "$REPO_ROOT/Cargo.toml" - mkdir -p "$REPO_ROOT/bin" - ln -sf "$built" "$bin" - log "binary ready: $bin (built $(date -r "$built" '+%Y-%m-%d %H:%M:%S'))" -} - -seed_wiki() { - mkdir -p "$WIKI_DIR" "$WIKI_DIR/diary" - if [[ ! -f "$WIKI_DIR/index.wiki" ]]; then - cat > "$WIKI_DIR/index.wiki" <<'EOF' -= Welcome to nuwiki = - -This is a scratch wiki created by start-nvim.sh. Edit, run commands, -or jump to the linked pages to test the plugin. - -== Smoke test == - -- [[Notes]] -- [[diary/]] -- [ ] Toggle me with -- [ ] Or with :VimwikiToggleListItem - -== Commands to try == - -- :VimwikiTOC -- :VimwikiGenerateLinks -- :VimwikiCheckLinks -- :VimwikiMakeDiaryNote -- :Vimwiki2HTMLBrowse - -== Tags == - -:smoke-test:sandbox: - -== Links == - -* [[Notes]] -* [[Notes#section-one]] -* https://example.com -EOF - fi - if [[ ! -f "$WIKI_DIR/Notes.wiki" ]]; then - cat > "$WIKI_DIR/Notes.wiki" <<'EOF' -= Notes = - -A second page so [[index]] has somewhere to point. - -== Section one == - -Anchor target for :checkhealth-style smoke tests. -EOF - fi -} - write_init() { mkdir -p "$DEV_DIR" cat > "$INIT_FILE" < "$WIKI_DIR/index.wiki" <<'EOF' -= Welcome to nuwiki = - -This is a scratch wiki created by start-vim.sh. Edit, run commands, -or jump to the linked pages to test the plugin. - -== Smoke test == - -- [[Notes]] -- [[diary/]] -- [ ] Toggle me with :VimwikiToggleListItem -- [ ] Inspect the LSP via :LspStatus - -== Commands to try == - -- :VimwikiTOC -- :VimwikiGenerateLinks -- :VimwikiCheckLinks -- :VimwikiMakeDiaryNote - -== Tags == - -:smoke-test:sandbox: -EOF - fi - if [[ ! -f "$WIKI_DIR/Notes.wiki" ]]; then - cat > "$WIKI_DIR/Notes.wiki" <<'EOF' -= Notes = - -A second page so [[index]] has somewhere to point. - -== Section one == - -Anchor target for cross-page link follows. -EOF - fi -} - write_vimrc() { mkdir -p "$DEV_DIR" cat > "$VIMRC" < /tmp/nuwiki-syndiag.log diff --git a/scripts/test-keymaps-vim.sh b/development/tests/test-keymaps-vim.sh similarity index 78% rename from scripts/test-keymaps-vim.sh rename to development/tests/test-keymaps-vim.sh index 77127c8..b816ad5 100755 --- a/scripts/test-keymaps-vim.sh +++ b/development/tests/test-keymaps-vim.sh @@ -1,16 +1,16 @@ #!/usr/bin/env bash # -# scripts/test-keymaps-vim.sh — Vim counterpart of test-keymaps.sh. +# development/tests/test-keymaps-vim.sh — Vim counterpart of test-keymaps.sh. # # Pure-VimL portion only. The LSP-roundtrip keymaps (``, # `=`, `-`, …) talk to the server via vim-lsp's timer-driven async # layer, which doesn't fire reliably inside `vim -e -s`. Those have -# Neovim coverage in scripts/test-keymaps.lua, exercising the same +# Neovim coverage in development/tests/test-keymaps.lua, exercising the same # Lua command layer + server binary. set -euo pipefail -REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" TMP="$(mktemp -d -t nuwiki-keymap-vim-test-XXXXXX)" trap 'rm -rf "$TMP"' EXIT @@ -33,6 +33,9 @@ VIMRC="$TMP/vimrc" cat > "$VIMRC" <"$TMP/vim.log" 2>&1 || true if [[ ! -f "$RESULTS" ]]; then diff --git a/scripts/test-keymaps-vim.vim b/development/tests/test-keymaps-vim.vim similarity index 70% rename from scripts/test-keymaps-vim.vim rename to development/tests/test-keymaps-vim.vim index 7a49e65..06bd04c 100644 --- a/scripts/test-keymaps-vim.vim +++ b/development/tests/test-keymaps-vim.vim @@ -1,4 +1,4 @@ -" scripts/test-keymaps-vim.vim — driven by scripts/test-keymaps-vim.sh. +" development/tests/test-keymaps-vim.vim — driven by development/tests/test-keymaps-vim.sh. " " Pure-VimL keymap regression suite. Plain Vim's LSP path " (vim-lsp + async.vim) talks to the server via timers, which won't @@ -6,7 +6,7 @@ " are restricted to the bindings whose RHS is implemented entirely in " VimL (header/link nav, `o`/`O` bullet continuation, the wrap step " of ``). LSP-roundtrip bindings (``, `=`, `-`, …) get -" their coverage from the Neovim harness in scripts/test-keymaps.lua +" their coverage from the Neovim harness in development/tests/test-keymaps.lua " — same Lua codepath, same server. let s:results = [] @@ -95,6 +95,107 @@ else call s:record(0, 'cmd.NuwikiIndex_defined', ':NuwikiIndex not registered') endif +" ===== Documented command surface ===== +" Every command must be reachable in BOTH the canonical :Nuwiki* form and +" the vimwiki-compatible :Vimwiki* form. The suffixes below are the command +" list minus the prefix. :NuwikiInstall is the one exception — it's a nuwiki-only +" maintenance command with no :Vimwiki* alias, so it's checked separately. + +let s:both_forms = [ + \ 'Index', 'TabIndex', 'UISelect', 'Goto', 'FollowLink', 'Backlinks', + \ 'NextLink', 'PrevLink', 'BaddLink', 'RenameFile', 'DeleteFile', + \ 'MakeDiaryNote', 'MakeYesterdayDiaryNote', 'MakeTomorrowDiaryNote', + \ 'DiaryIndex', 'DiaryGenerateLinks', 'DiaryNextDay', 'DiaryPrevDay', + \ 'TOC', 'GenerateLinks', 'CheckLinks', 'SearchTags', 'GenerateTagLinks', + \ 'RebuildTags', '2HTML', '2HTMLBrowse', 'All2HTML', 'Rss', + \ ] +for s:suffix in s:both_forms + for s:prefix in ['Nuwiki', 'Vimwiki'] + let s:cmd = s:prefix . s:suffix + let s:ok = exists(':' . s:cmd) == 2 + call s:record(s:ok ? 1 : 0, 'surface.' . s:cmd, + \ s:ok ? '' : ':' . s:cmd . ' not registered') + endfor +endfor +let s:ok = exists(':NuwikiInstall') == 2 +call s:record(s:ok ? 1 : 0, 'surface.NuwikiInstall', + \ s:ok ? '' : ':NuwikiInstall not registered') + +" ===== Pure-VimL command invocation (cursor movement) ===== +" :NuwikiNextLink / :NuwikiPrevLink wrap search('\[\[', …) so they run +" entirely in VimL and move the cursor without touching the LSP. + +call s:set_buf(['intro [[A]] more [[B]] end']) +call cursor(1, 1) +silent NuwikiNextLink +call s:record( + \ col('.') == 7 ? 1 : 0, + \ 'invoke.NuwikiNextLink_jumps_to_first_link', + \ 'col=' . col('.')) +silent NuwikiNextLink +call s:record( + \ col('.') == 18 ? 1 : 0, + \ 'invoke.NuwikiNextLink_jumps_to_second_link', + \ 'col=' . col('.')) +silent NuwikiPrevLink +call s:record( + \ col('.') == 7 ? 1 : 0, + \ 'invoke.NuwikiPrevLink_jumps_back', + \ 'col=' . col('.')) + +" The :Vimwiki* alias must drive the identical motion. +call cursor(1, 1) +silent VimwikiNextLink +call s:record( + \ col('.') == 7 ? 1 : 0, + \ 'invoke.VimwikiNextLink_jumps_to_first_link', + \ 'col=' . col('.')) + +" ===== Documented mapping surface ===== +" Every documented mapping must resolve to a buffer-local mapping in the +" mode(s) the doc lists it under. The mouse group is opt-in — the shell +" wrapper sets g:nuwiki_mouse_mappings before the ftplugin loads. + +function! s:has_map(lhs, mode) abort + let l:d = maparg(a:lhs, a:mode, 0, 1) + return !empty(l:d) && get(l:d, 'buffer', 0) +endfunction + +let s:mapping_surface = [ + \ ['', 'n'], ['', 'n'], ['', 'n'], ['', 'n'], + \ ['', 'n'], ['', 'n'], ['', 'n'], ['+', 'nx'], + \ ['', 'nx'], ['', 'nx'], ['', 'nx'], + \ ['gnt', 'n'], ['gln', 'nx'], ['glp', 'nx'], ['glx', 'nx'], + \ ['glh', 'n'], ['gll', 'n'], ['gLh', 'n'], ['gLl', 'n'], + \ ['glr', 'n'], ['gLr', 'n'], ['gl', 'n'], ['gL', 'n'], + \ ['o', 'n'], ['O', 'n'], + \ ['=', 'n'], ['-', 'n'], [']]', 'n'], ['[[', 'n'], + \ [']=', 'n'], ['[=', 'n'], [']u', 'n'], ['[u', 'n'], + \ ['gqq', 'n'], ['gq1', 'n'], ['gww', 'n'], ['gw1', 'n'], + \ ['', 'n'], ['', 'n'], + \ ['ww', 'n'], ['ws', 'n'], ['wi', 'n'], + \ ['ww', 'n'], ['wy', 'n'], + \ ['wt', 'n'], ['wi', 'n'], + \ ['wn', 'n'], ['wd', 'n'], ['wr', 'n'], + \ ['wh', 'n'], ['whh', 'n'], ['wha', 'n'], + \ ['wc', 'nx'], ['', 'n'], ['', 'n'], + \ ['<2-LeftMouse>', 'n'], ['', 'n'], ['', 'n'], + \ ['', 'n'], ['', 'n'], + \ ['ah', 'ox'], ['ih', 'ox'], ['aH', 'ox'], ['iH', 'ox'], + \ ['al', 'ox'], ['il', 'ox'], ['a\', 'ox'], ['i\', 'ox'], + \ ['ac', 'ox'], ['ic', 'ox'], + \ ['', 'i'], ['', 'i'], ['', 'i'], + \ ['', 'i'], ['', 'i'], + \ ['', 'i'], ['', 'i'], ['', 'i'], + \ ] +for s:entry in s:mapping_surface + for s:m in split(s:entry[1], '\zs') + let s:ok = s:has_map(s:entry[0], s:m) + call s:record(s:ok ? 1 : 0, 'map[' . s:m . '].' . s:entry[0], + \ s:ok ? '' : s:entry[0] . ' (' . s:m . ') not mapped buffer-local') + endfor +endfor + " ===== Header nav (pure VimL) ===== call s:run('headers.]]_jumps_to_next', { @@ -121,6 +222,12 @@ call s:run('headers.]=_jumps_to_next_sibling', { \ 'keys': ']=', \ 'expect_cursor_line': 4, \ }) +call s:run('headers.[=_jumps_to_prev_sibling', { + \ 'lines': ['= A =', '== child ==', 'body', '= B ='], + \ 'cursor': [4, 1], + \ 'keys': '[=', + \ 'expect_cursor_line': 1, + \ }) " ===== Link nav (pure VimL) ===== diff --git a/scripts/test-keymaps.lua b/development/tests/test-keymaps.lua similarity index 70% rename from scripts/test-keymaps.lua rename to development/tests/test-keymaps.lua index d15ba6e..68aaac8 100644 --- a/scripts/test-keymaps.lua +++ b/development/tests/test-keymaps.lua @@ -1,4 +1,4 @@ --- scripts/test-keymaps.lua — driven by scripts/test-keymaps.sh. +-- development/tests/test-keymaps.lua — driven by development/tests/test-keymaps.sh. -- -- Headless Neovim harness for the buffer-local keymaps registered by -- `ftplugin/vimwiki.vim` + `lua/nuwiki/keymaps.lua`. Each case sets up @@ -123,6 +123,105 @@ vim.defer_fn(function() end record(true, 'lsp.attached', 'client=' .. client.name) + -- ===== Documented command surface ===== + -- Every command must be reachable in BOTH the canonical :Nuwiki* form + -- and the vimwiki-compatible :Vimwiki* form. The suffixes below are the + -- command list minus the prefix. :NuwikiInstall is the one exception — a + -- nuwiki-only maintenance command with no :Vimwiki* alias. + local both_forms = { + 'Index', 'TabIndex', 'UISelect', 'Goto', 'FollowLink', 'Backlinks', + 'NextLink', 'PrevLink', 'BaddLink', 'RenameFile', 'DeleteFile', + 'MakeDiaryNote', 'MakeYesterdayDiaryNote', 'MakeTomorrowDiaryNote', + 'DiaryIndex', 'DiaryGenerateLinks', 'DiaryNextDay', 'DiaryPrevDay', + 'TOC', 'GenerateLinks', 'CheckLinks', 'SearchTags', 'GenerateTagLinks', + 'RebuildTags', '2HTML', '2HTMLBrowse', 'All2HTML', 'Rss', + } + for _, suffix in ipairs(both_forms) do + for _, prefix in ipairs({ 'Nuwiki', 'Vimwiki' }) do + local cmd = prefix .. suffix + local exists = vim.fn.exists(':' .. cmd) == 2 + record(exists, 'surface.' .. cmd, exists and '' or (':' .. cmd .. ' not registered')) + end + end + do + local exists = vim.fn.exists(':NuwikiInstall') == 2 + record(exists, 'surface.NuwikiInstall', + exists and '' or ':NuwikiInstall not registered') + end + + -- ===== Pure-Lua command invocation (cursor movement) ===== + -- :NuwikiNextLink / :NuwikiPrevLink wrap the link-search motion and run + -- without the LSP, so we can assert exact cursor movement here. + do + local ok, err = pcall(function() + set_buf({ 'intro [[A]] more [[B]] end' }) + vim.api.nvim_win_set_cursor(0, { 1, 0 }) + vim.cmd('NuwikiNextLink') + local c1 = vim.api.nvim_win_get_cursor(0)[2] + if c1 ~= 6 then error('NextLink #1 col=' .. c1 .. ' want 6') end + vim.cmd('NuwikiNextLink') + local c2 = vim.api.nvim_win_get_cursor(0)[2] + if c2 ~= 17 then error('NextLink #2 col=' .. c2 .. ' want 17') end + vim.cmd('VimwikiPrevLink') + local c3 = vim.api.nvim_win_get_cursor(0)[2] + if c3 ~= 6 then error('PrevLink col=' .. c3 .. ' want 6') end + end) + record(ok, 'invoke.next_prev_link_cursor_movement', ok and '' or tostring(err)) + end + + -- ===== Documented mapping surface ===== + -- Every documented mapping must resolve to a buffer-local mapping in + -- the mode(s) the doc lists it under. `modes` is a string of mode + -- letters: n/x/o/i. The mouse group is opt-in — the harness enables it + -- via setup({ mappings = { mouse = true } }). + local function has_map(lhs, mode) + local d = vim.fn.maparg(lhs, mode, false, true) + return next(d) ~= nil and d.buffer == 1 + end + local mapping_surface = { + -- Links + { '', 'n' }, { '', 'n' }, { '', 'n' }, { '', 'n' }, + { '', 'n' }, { '', 'n' }, { '', 'n' }, { '+', 'nx' }, + -- Lists + { '', 'nx' }, { '', 'nx' }, { '', 'nx' }, + { 'gnt', 'n' }, { 'gln', 'nx' }, { 'glp', 'nx' }, { 'glx', 'nx' }, + { 'glh', 'n' }, { 'gll', 'n' }, { 'gLh', 'n' }, { 'gLl', 'n' }, + { 'glr', 'n' }, { 'gLr', 'n' }, { 'gl', 'n' }, { 'gL', 'n' }, + { 'o', 'n' }, { 'O', 'n' }, + -- Headers + { '=', 'n' }, { '-', 'n' }, { ']]', 'n' }, { '[[', 'n' }, + { ']=', 'n' }, { '[=', 'n' }, { ']u', 'n' }, { '[u', 'n' }, + -- Tables + { 'gqq', 'n' }, { 'gq1', 'n' }, { 'gww', 'n' }, { 'gw1', 'n' }, + { '', 'n' }, { '', 'n' }, + -- Wiki / diary / export + { 'ww', 'n' }, { 'ws', 'n' }, { 'wi', 'n' }, + { 'ww', 'n' }, { 'wy', 'n' }, + { 'wt', 'n' }, { 'wi', 'n' }, + { 'wn', 'n' }, { 'wd', 'n' }, { 'wr', 'n' }, + { 'wh', 'n' }, { 'whh', 'n' }, { 'wha', 'n' }, + { 'wc', 'nx' }, { '', 'n' }, { '', 'n' }, + -- Mouse (opt-in) + { '<2-LeftMouse>', 'n' }, { '', 'n' }, { '', 'n' }, + { '', 'n' }, { '', 'n' }, + -- Text objects (operator-pending + visual) + { 'ah', 'ox' }, { 'ih', 'ox' }, { 'aH', 'ox' }, { 'iH', 'ox' }, + { 'al', 'ox' }, { 'il', 'ox' }, { 'a\\', 'ox' }, { 'i\\', 'ox' }, + { 'ac', 'ox' }, { 'ic', 'ox' }, + -- Insert-mode + { '', 'i' }, { '', 'i' }, { '', 'i' }, + { '', 'i' }, { '', 'i' }, + { '', 'i' }, { '', 'i' }, { '', 'i' }, + } + for _, entry in ipairs(mapping_surface) do + local lhs, modes = entry[1], entry[2] + for m in modes:gmatch('.') do + local ok = has_map(lhs, m) + record(ok, 'map[' .. m .. '].' .. lhs, + ok and '' or (lhs .. ' (' .. m .. ') not mapped buffer-local')) + end + end + -- ===== Lists ===== run('list.toggle_via_C-Space', { @@ -167,6 +266,84 @@ vim.defer_fn(function() expect_line = '- [ ] done', expect_at = 1, }) + -- `glp` must cycle BACKWARD (regression for the glp==gln bug). From + -- `[.]` the backward step lands on `[ ]`, whereas `gln` would advance + -- to `[o]`. + run('list.cycle_back_via_glp', { + lines = { '- [.] task' }, + cursor = { 1, 0 }, + keys = 'glp', + expect_line = '- [ ] task', + expect_at = 1, + }) + run('list.cycle_back_via_glp_wraps', { + -- `[ ]` backward wraps to the top of the progression (`[X]`). + lines = { '- [ ] task' }, + cursor = { 1, 0 }, + keys = 'glp', + expect_line = '- [X] task', + expect_at = 1, + }) + + -- ===== Change level (glh/gll) ===== + + run('list.indent_via_gll', { + lines = { '- item' }, + cursor = { 1, 0 }, + keys = 'gll', + expect_line = ' - item', + expect_at = 1, + }) + run('list.dedent_via_glh', { + lines = { ' - item' }, + cursor = { 1, 2 }, + keys = 'glh', + expect_line = '- item', + expect_at = 1, + }) + + -- ===== Renumber (glr / gLr) ===== + + run('list.renumber_via_glr', { + lines = { '5. one', '9. two', '2. three' }, + cursor = { 1, 0 }, + keys = 'glr', + expect_lines = { '1. one', '2. two', '3. three' }, + }) + + -- ===== Remove done (gl current list / gL whole doc) ===== + + -- `gl` removes done items from the cursor's list only; the + -- second list's done item survives. + run('list.remove_done_current_list_via_gl_space', { + lines = { + '- [X] done a', '- [ ] todo a', '', + 'paragraph', '', + '- [ ] todo b', '- [X] done b', + }, + cursor = { 1, 0 }, + keys = 'gl', + expect_lines = { + '- [ ] todo a', '', + 'paragraph', '', + '- [ ] todo b', '- [X] done b', + }, + }) + -- `gL` sweeps the whole buffer regardless of cursor position. + run('list.remove_done_whole_buffer_via_gL_space', { + lines = { + '- [X] done a', '- [ ] todo a', '', + 'paragraph', '', + '- [ ] todo b', '- [X] done b', + }, + cursor = { 1, 0 }, + keys = 'gL', + expect_lines = { + '- [ ] todo a', '', + 'paragraph', '', + '- [ ] todo b', + }, + }) -- ===== Next task (gnt) ===== @@ -247,7 +424,7 @@ vim.defer_fn(function() wait_ms = 200, }) - -- ===== Link helpers (§13.1 Cluster C) ===== + -- ===== Link helpers (Cluster C) ===== run('links.normalize_via_+', { lines = { 'MyPage rest' }, diff --git a/scripts/test-keymaps.sh b/development/tests/test-keymaps.sh similarity index 83% rename from scripts/test-keymaps.sh rename to development/tests/test-keymaps.sh index e3061aa..72b115a 100755 --- a/scripts/test-keymaps.sh +++ b/development/tests/test-keymaps.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # -# scripts/test-keymaps.sh — drive scripts/test-keymaps.lua under +# development/tests/test-keymaps.sh — drive development/tests/test-keymaps.lua under # headless Neovim and report pass/fail per buffer-local keymap. # # The Lua harness needs a real LSP attachment (most keymaps dispatch @@ -14,7 +14,7 @@ set -euo pipefail -REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" TMP="$(mktemp -d -t nuwiki-keymap-test-XXXXXX)" trap 'rm -rf "$TMP"' EXIT @@ -38,7 +38,9 @@ cat > "$INIT" <"$TMP/nvim.log" 2>&1 || true if [[ ! -f "$RESULTS" ]]; then diff --git a/doc/nuwiki.txt b/doc/nuwiki.txt index ff412da..5c267c6 100644 --- a/doc/nuwiki.txt +++ b/doc/nuwiki.txt @@ -228,63 +228,68 @@ Vim-specific globals ~ Override the auto-discovered server binary path. If set and the file is readable, it overrides the `{plugin}/bin/nuwiki-ls` default. +============================================================================== ============================================================================== 5. COMMANDS *nuwiki-commands* -Every command listed below is registered buffer-local on `.wiki` files -under both names: the `:Vimwiki*` form (for migration from vimwiki) and -the canonical `:Nuwiki*` alias. Only the `:Vimwiki*` form is shown. +All commands are available in both forms: +- Canonical form: :Nuwiki* +- Vimwiki-compatible form: :Vimwiki* + +Every command listed below is registered buffer-logic on `.wiki` files. +Only the `:Nuwiki*` form is shown in this document for brevity. + Wiki / navigation ~ - *:VimwikiIndex* -:VimwikiIndex [{count}] + *:NuwikiIndex* +:NuwikiIndex [{count}] Open wiki N's index page (default: 1). - *:VimwikiTabIndex* -:VimwikiTabIndex [{count}] + *:NuwikiTabIndex* +:NuwikiTabIndex [{count}] Same, in a new tab. - *:VimwikiUISelect* -:VimwikiUISelect + *:NuwikiUISelect* +:NuwikiUISelect Pick a wiki from a list (multi-wiki only). Uses |vim.ui.select| on Neovim, |inputlist()| on Vim. - *:VimwikiGoto* -:VimwikiGoto {page} + *:NuwikiGoto* +:NuwikiGoto {page} Open `{page}.wiki` by name. - *:VimwikiFollowLink* -:VimwikiFollowLink + *:NuwikiFollowLink* +:NuwikiFollowLink Follow the link under the cursor. - *:VimwikiBacklinks* -:VimwikiBacklinks + *:NuwikiBacklinks* +:NuwikiBacklinks Show all references to the current page. - *:VimwikiNextLink* -:VimwikiNextLink - *:VimwikiPrevLink* -:VimwikiPrevLink + *:NuwikiNextLink* +:NuwikiNextLink + *:NuwikiPrevLink* +:NuwikiPrevLink Jump to the next / previous wikilink on the page. - *:VimwikiBaddLink* -:VimwikiBaddLink + *:NuwikiBaddLink* +:NuwikiBaddLink Add the target of the link under the cursor to the buffer list. - *:VimwikiRenameFile* -:VimwikiRenameFile + *:NuwikiRenameFile* +:NuwikiRenameFile Prompt for a new name; rename the current page and rewrite every inbound link. - *:VimwikiDeleteFile* -:VimwikiDeleteFile + *:NuwikiDeleteFile* +:NuwikiDeleteFile Delete the current page and its on-disk file. Diary ~ - *:VimwikiMakeDiaryNote* -:VimwikiMakeDiaryNote + *:NuwikiMakeDiaryNote* +:NuwikiMakeDiaryNote Open today's diary entry. With `diary_frequency = 'weekly'` (or `'monthly'` / `'yearly'`) this addresses this week's / this month's / this year's entry instead. The file stems follow: @@ -294,74 +299,74 @@ Diary ~ monthly YYYY-MM 2026-05.wiki yearly YYYY 2026.wiki - *:VimwikiMakeYesterdayDiaryNote* -:VimwikiMakeYesterdayDiaryNote - *:VimwikiMakeTomorrowDiaryNote* -:VimwikiMakeTomorrowDiaryNote + *:NuwikiMakeYesterdayDiaryNote* +:NuwikiMakeYesterdayDiaryNote + *:NuwikiMakeTomorrowDiaryNote* +:NuwikiMakeTomorrowDiaryNote Step the diary back / forward by one period at the configured cadence. - *:VimwikiDiaryIndex* -:VimwikiDiaryIndex + *:NuwikiDiaryIndex* +:NuwikiDiaryIndex Open the diary index page. - *:VimwikiDiaryGenerateLinks* -:VimwikiDiaryGenerateLinks + *:NuwikiDiaryGenerateLinks* +:NuwikiDiaryGenerateLinks Rebuild the diary index page from the entries currently on disk. - *:VimwikiDiaryNextDay* -:VimwikiDiaryNextDay - *:VimwikiDiaryPrevDay* -:VimwikiDiaryPrevDay + *:NuwikiDiaryNextDay* +:NuwikiDiaryNextDay + *:NuwikiDiaryPrevDay* +:NuwikiDiaryPrevDay Walk to the next / previous indexed diary entry at the same cadence as the current one. Page generation ~ - *:VimwikiTOC* -:VimwikiTOC + *:NuwikiTOC* +:NuwikiTOC Generate or refresh the table of contents on the current page. - *:VimwikiGenerateLinks* -:VimwikiGenerateLinks + *:NuwikiGenerateLinks* +:NuwikiGenerateLinks Insert a flat list of every page in the wiki under the cursor. - *:VimwikiCheckLinks* -:VimwikiCheckLinks + *:NuwikiCheckLinks* +:NuwikiCheckLinks Send every broken link in the workspace to the |quickfix| list. Tags ~ - *:VimwikiSearchTags* -:VimwikiSearchTags {tag} + *:NuwikiSearchTags* +:NuwikiSearchTags {tag} Quickfix listing every `:tag:` occurrence. - *:VimwikiGenerateTagLinks* -:VimwikiGenerateTagLinks [tag] + *:NuwikiGenerateTagLinks* +:NuwikiGenerateTagLinks [tag] Insert a section linking every page that has ``. With no argument, generates a section per tag found in the workspace. - *:VimwikiRebuildTags* -:VimwikiRebuildTags + *:NuwikiRebuildTags* +:NuwikiRebuildTags Force a full workspace re-index. HTML export ~ - *:Vimwiki2HTML* -:Vimwiki2HTML + *:Nuwiki2HTML* +:Nuwiki2HTML Render the current page to HTML. - *:Vimwiki2HTMLBrowse* -:Vimwiki2HTMLBrowse + *:Nuwiki2HTMLBrowse* +:Nuwiki2HTMLBrowse Render the current page and open it in the default browser. - *:VimwikiAll2HTML* -:VimwikiAll2HTML[!] + *:NuwikiAll2HTML* +:NuwikiAll2HTML[!] Export every page in the wiki. `!` forces a full rebuild; otherwise only out-of-date pages are re-rendered. - *:VimwikiRss* -:VimwikiRss + *:NuwikiRss* +:NuwikiRss Write `rss.xml` summarising recent diary entries. Other ~ @@ -522,7 +527,7 @@ The `diary_frequency` per-wiki option controls the cadence: monthly YYYY-MM e.g. `2026-05.wiki` yearly YYYY e.g. `2026.wiki` -`:VimwikiMakeDiaryNote` opens the entry for the current period at the +`:NuwikiMakeDiaryNote` opens the entry for the current period at the configured cadence; the yesterday / tomorrow commands step back and forward by one period. `` / `` walk between indexed entries of the same flavour as the one under the cursor — so on a @@ -530,7 +535,7 @@ weekly page, navigation stays weekly. Index ~ -`:VimwikiDiaryGenerateLinks` rebuilds the diary index page (`diary.wiki` +`:NuwikiDiaryGenerateLinks` rebuilds the diary index page (`diary.wiki` by default) with a chronological list of every entry on disk, grouped by year / month. The page name is set by `diary_index`; the heading is set by `diary_header`. @@ -564,8 +569,8 @@ column moves, cell navigation, new-row insertion). ============================================================================== 11. HTML EXPORT *nuwiki-html* -`:Vimwiki2HTML` renders the current page; `:Vimwiki2HTMLBrowse` opens -the result in the default browser; `:VimwikiAll2HTML` exports every +`:Nuwiki2HTML` renders the current page; `:Nuwiki2HTMLBrowse` opens +the result in the default browser; `:NuwikiAll2HTML` exports every page (incremental by default; `!` forces a full rebuild). Per-wiki knobs: @@ -579,7 +584,7 @@ Per-wiki knobs: `exclude_files` Glob patterns excluded from export. Templates may include `{title}`, `{rootpath}`, `{content}`, `{css}`, -`{date}` placeholders. RSS for the diary is written by `:VimwikiRss`. +`{date}` placeholders. RSS for the diary is written by `:NuwikiRss`. ============================================================================== 12. FOLDING *nuwiki-folding* @@ -631,7 +636,7 @@ match vimwiki. To migrate: Existing pages, diary entries, tags, and templates work without modification. The first workspace scan after launch may show an empty -`:VimwikiCheckLinks` result until the background index completes; +`:NuwikiCheckLinks` result until the background index completes; subsequent runs are instant. vim:tw=78:ts=8:ft=help:norl: diff --git a/ftplugin/vimwiki.vim b/ftplugin/vimwiki.vim index cf1bf62..c181218 100644 --- a/ftplugin/vimwiki.vim +++ b/ftplugin/vimwiki.vim @@ -1,9 +1,9 @@ -" ftplugin/nuwiki.vim — per-buffer settings + Phase 19 command/keymap glue. +" ftplugin/nuwiki.vim — per-buffer settings + command/keymap glue. " " Wires up: " - basic edit-time options (commentstring, suffixesadd, …) -" - every `:Vimwiki*` / `:Nuwiki*` command from SPEC §12.10 that has a -" server-side counterpart (deferred §13.1 commands stub out with a +" - every `:Vimwiki*` / `:Nuwiki*` command that has a +" server-side counterpart (deferred commands stub out with a " "not yet implemented" notification) " - on Neovim: keymaps + text objects + folding via " `nuwiki.ftplugin.attach()` @@ -78,7 +78,7 @@ if !has('nvim') command! -buffer -nargs=? VimwikiSearchTags call nuwiki#commands#tags_search() command! -buffer -nargs=? VimwikiGenerateTagLinks call nuwiki#commands#tags_generate_links() - " §13.1 deferred stubs. + " Deferred stubs. command! -buffer -nargs=1 VimwikiTable call nuwiki#commands#table_insert() command! -buffer VimwikiTableMoveColumnLeft call nuwiki#commands#table_move_left() command! -buffer VimwikiTableMoveColumnRight call nuwiki#commands#table_move_right() @@ -115,6 +115,21 @@ if !has('nvim') command! -buffer -nargs=? NuwikiGenerateTagLinks call nuwiki#commands#tags_generate_links() command! -buffer -nargs=1 NuwikiGoto call nuwiki#commands#wiki_goto_page() + " Canonical :Nuwiki* names that mirror the documented surface. + " The older :Nuwiki* spellings above stay defined for back-compat. + command! -buffer NuwikiNextLink call nuwiki#commands#link_next() + command! -buffer NuwikiPrevLink call nuwiki#commands#link_prev() + command! -buffer NuwikiBaddLink call nuwiki#commands#badd_link() + command! -buffer NuwikiMakeDiaryNote call nuwiki#commands#diary_today() + command! -buffer NuwikiMakeYesterdayDiaryNote call nuwiki#commands#diary_yesterday() + command! -buffer NuwikiMakeTomorrowDiaryNote call nuwiki#commands#diary_tomorrow() + command! -buffer NuwikiDiaryGenerateLinks call nuwiki#commands#diary_generate_index() + command! -buffer NuwikiDiaryNextDay call nuwiki#commands#diary_next() + command! -buffer NuwikiDiaryPrevDay call nuwiki#commands#diary_prev() + command! -buffer Nuwiki2HTML call nuwiki#commands#export_current() + command! -buffer Nuwiki2HTMLBrowse call nuwiki#commands#export_browse() + command! -buffer -bang NuwikiAll2HTML if 0 | call nuwiki#commands#export_all_force() | else | call nuwiki#commands#export_all() | endif + " ===== Default key mappings (parity with upstream vimwiki) ===== " " Users can opt out by setting `g:nuwiki_no_default_mappings = 1` @@ -162,8 +177,8 @@ if !has('nvim') nnoremap gnt :call nuwiki#commands#next_task() nnoremap gln :call nuwiki#commands#cycle_list_item() xnoremap gln :call nuwiki#commands#cycle_list_item() - nnoremap glp :call nuwiki#commands#cycle_list_item() - xnoremap glp :call nuwiki#commands#cycle_list_item() + nnoremap glp :call nuwiki#commands#cycle_list_item_back() + xnoremap glp :call nuwiki#commands#cycle_list_item_back() nnoremap glx :call nuwiki#commands#reject_list_item() xnoremap glx :call nuwiki#commands#reject_list_item() nnoremap glh :call nuwiki#commands#list_change_level(-1, 0) @@ -173,7 +188,7 @@ if !has('nvim') nnoremap glr :call nuwiki#commands#list_renumber() nnoremap gLr :call nuwiki#commands#list_renumber_all() nnoremap gl :call nuwiki#commands#list_remove_done() - nnoremap gL :call nuwiki#commands#list_remove_done() + nnoremap gL :call nuwiki#commands#list_remove_done_all() nnoremap o :call nuwiki#commands#open_below_with_bullet() nnoremap O :call nuwiki#commands#open_above_with_bullet() @@ -316,7 +331,7 @@ command! -buffer VimwikiRebuildTags lua require('nuwiki.commands'). command! -buffer -nargs=? VimwikiSearchTags lua require('nuwiki.commands').tags_search() command! -buffer -nargs=? VimwikiGenerateTagLinks lua require('nuwiki.commands').tags_generate_links() -" §13.1 deferred — stubs notify the user instead of erroring. +" Deferred — stubs notify the user instead of erroring. command! -buffer -nargs=1 VimwikiTable lua require('nuwiki.commands').table_insert() command! -buffer VimwikiTableMoveColumnLeft lua require('nuwiki.commands').table_move_column_left() command! -buffer VimwikiTableMoveColumnRight lua require('nuwiki.commands').table_move_column_right() @@ -355,5 +370,19 @@ command! -buffer -nargs=? NuwikiSearchTags lua require('nuwiki.commands' command! -buffer -nargs=? NuwikiGenerateTagLinks lua require('nuwiki.commands').tags_generate_links() command! -buffer -nargs=1 NuwikiGoto lua require('nuwiki.commands').wiki_goto_page() -" Phase 19 buffer attach: keymaps + text objects + folding. +" Canonical :Nuwiki* names that mirror the documented surface. +" The older :Nuwiki* spellings above stay defined for back-compat. +command! -buffer NuwikiNextLink lua require('nuwiki.commands').link_next() +command! -buffer NuwikiPrevLink lua require('nuwiki.commands').link_prev() +command! -buffer NuwikiBaddLink lua require('nuwiki.commands').badd_link() +command! -buffer NuwikiMakeDiaryNote lua require('nuwiki.commands').diary_today() +command! -buffer NuwikiMakeYesterdayDiaryNote lua require('nuwiki.commands').diary_yesterday() +command! -buffer NuwikiMakeTomorrowDiaryNote lua require('nuwiki.commands').diary_tomorrow() +command! -buffer NuwikiDiaryNextDay lua require('nuwiki.commands').diary_next() +command! -buffer NuwikiDiaryPrevDay lua require('nuwiki.commands').diary_prev() +command! -buffer Nuwiki2HTML lua require('nuwiki.commands').export_current() +command! -buffer Nuwiki2HTMLBrowse lua require('nuwiki.commands').export_browse() +command! -buffer -bang NuwikiAll2HTML execute (0 ? "lua require('nuwiki.commands').export_all_force()" : "lua require('nuwiki.commands').export_all()") + +" Buffer attach: keymaps + text objects + folding. lua require('nuwiki.ftplugin').attach(0) diff --git a/lua/nuwiki/commands.lua b/lua/nuwiki/commands.lua index 92923c1..1b65206 100644 --- a/lua/nuwiki/commands.lua +++ b/lua/nuwiki/commands.lua @@ -1,5 +1,5 @@ -- lua/nuwiki/commands.lua — Lua wrappers around every `nuwiki.*` --- `workspace/executeCommand` the server advertises. Phase 19 plumbing. +-- `workspace/executeCommand` the server advertises. -- -- Each public function is named after its `:Vimwiki*` / `:Nuwiki*` -- counterpart so `ftplugin/nuwiki.vim` can wire them up declaratively. @@ -323,6 +323,12 @@ end M.toggle_list_item = _exec_pos('nuwiki.list.toggleCheckbox') M.cycle_list_item = _exec_pos('nuwiki.list.cycleCheckbox') +-- `glp` cycles backward — same command, `reverse = true` in the payload. +function M.cycle_list_item_back() + local a = pos_args() + a[1].reverse = true + exec('nuwiki.list.cycleCheckbox', a) +end M.reject_list_item = _exec_pos('nuwiki.list.rejectCheckbox') M.heading_add_level = _exec_pos('nuwiki.heading.addLevel') M.heading_remove_level = _exec_pos('nuwiki.heading.removeLevel') @@ -462,7 +468,7 @@ function M.rename_file() vim.lsp.buf.rename() end --- ===== Deferred placeholders (§13.1) ===== +-- ===== Deferred placeholders ===== -- -- These commands aren't implemented on the server yet. Stub them so the -- `:Vimwiki*` compat surface exists and users get a clear message @@ -471,15 +477,22 @@ end local function _not_yet(name) return function() vim.notify( - 'nuwiki: ' .. name .. ' is not yet implemented — see SPEC §13.1', + 'nuwiki: ' .. name .. ' is not yet implemented', vim.log.levels.WARN ) end end --- §13.1 Cluster A — list rewriters. +-- Cluster A — list rewriters. +-- `gl` — remove done items from the current list only. Passing the +-- cursor position scopes the server to the contiguous list block under it. function M.list_remove_done() + exec('nuwiki.list.removeDone', pos_args()) +end + +-- `gL` — remove done items across the whole buffer (no position). +function M.list_remove_done_all() exec('nuwiki.list.removeDone', uri_args()) end @@ -878,7 +891,7 @@ function M.smart_shift_tab() ) end --- §13.1 Cluster B — table rewriters. +-- Cluster B — table rewriters. function M.table_insert(cols, rows) cols = tonumber(cols) or 3 @@ -961,7 +974,7 @@ function M.colorize(color) vim.api.nvim_buf_set_lines(0, row - 1, row, false, { new_line }) end --- §13.1 Cluster C — link helpers. +-- Cluster C — link helpers. function M.paste_link() exec('nuwiki.link.pasteWikilink', pos_args()) diff --git a/lua/nuwiki/config.lua b/lua/nuwiki/config.lua index f7d048b..f937610 100644 --- a/lua/nuwiki/config.lua +++ b/lua/nuwiki/config.lua @@ -1,12 +1,11 @@ -- lua/nuwiki/config.lua — defaults and user-config merge. -- --- Schema mirrors SPEC §7.5 + §12.11. Keep this aligned with --- `doc/nuwiki.txt`. +-- Keep this aligned with `doc/nuwiki.txt`. local M = {} M.defaults = { - -- v1.0 single-wiki shorthand. The server desugars these into a + -- Single-wiki shorthand. The server desugars these into a -- one-entry `wikis = {…}` list. Use either this or the multi-wiki -- form below; if both are set, `wikis` wins. wiki_root = '~/vimwiki', @@ -14,12 +13,12 @@ M.defaults = { syntax = 'vimwiki', log_level = 'warn', - -- v1.1 multi-wiki shape. Each entry honours every per-wiki key the + -- Multi-wiki shape. Each entry honours every per-wiki key the -- server understands. Leave nil to fall through to the shorthand -- above. -- - -- Per-wiki keys (all optional, server defaults documented in SPEC - -- §12.11): + -- Per-wiki keys (all optional, server defaults documented in + -- `doc/nuwiki.txt`): -- name, root, file_extension, syntax, index -- diary_rel_path, diary_index, diary_frequency, -- diary_start_week_day, diary_caption_level, diary_sort, @@ -31,7 +30,7 @@ M.defaults = { -- links_space_char, nested_syntaxes wikis = nil, - -- Phase 19: per-buffer glue. Each subgroup mirrors vimwiki's + -- Per-buffer glue. Each subgroup mirrors vimwiki's -- `g:vimwiki_key_mappings` shape and can be flipped off -- independently to suppress that group of keymaps. mappings = { @@ -47,7 +46,7 @@ M.defaults = { mouse = false, -- <2-LeftMouse>, , … (opt-in) }, - -- Phase 19: folding. `'lsp'` uses the server's `foldingRange` + -- Folding. `'lsp'` uses the server's `foldingRange` -- provider (Neovim 0.11+ wires it automatically). `'expr'` falls -- back to a regex-based `foldexpr`. `'off'` skips folding setup. folding = 'lsp', diff --git a/lua/nuwiki/ftplugin.lua b/lua/nuwiki/ftplugin.lua index f729560..d48a1c6 100644 --- a/lua/nuwiki/ftplugin.lua +++ b/lua/nuwiki/ftplugin.lua @@ -1,6 +1,6 @@ -- lua/nuwiki/ftplugin.lua — per-buffer hookup invoked by --- `ftplugin/nuwiki.vim`. Centralises the Lua side of Phase 19 so the --- VimL ftplugin stays a one-liner regardless of which subgroups are +-- `ftplugin/nuwiki.vim`. Centralises the Lua side of the per-buffer +-- glue so the VimL ftplugin stays a one-liner regardless of which subgroups are -- enabled. local M = {} diff --git a/lua/nuwiki/health.lua b/lua/nuwiki/health.lua index 524e04b..beea5bd 100644 --- a/lua/nuwiki/health.lua +++ b/lua/nuwiki/health.lua @@ -1,7 +1,7 @@ -- lua/nuwiki/health.lua — `:checkhealth nuwiki` target. -- --- v1.0 checks per SPEC §7.6 (binary, version, filetype, LSP attach). --- v1.1 §12.10 additions: registered LSP commands, indexed wikis, HTML +-- Core checks: binary, version, filetype, LSP attach. +-- Additional checks: registered LSP commands, indexed wikis, HTML -- output path writability, default keymaps installed. local M = {} diff --git a/lua/nuwiki/init.lua b/lua/nuwiki/init.lua index 7a0217b..b37a471 100644 --- a/lua/nuwiki/init.lua +++ b/lua/nuwiki/init.lua @@ -5,7 +5,7 @@ -- require('nuwiki').install() — install / build the language server -- require('nuwiki').health() — :checkhealth nuwiki target -- --- Per SPEC §6.2/§6.3 this layer is wiring only. Everything substantive +-- This layer is wiring only. Everything substantive -- happens in the Rust language server. local M = {} diff --git a/lua/nuwiki/install.lua b/lua/nuwiki/install.lua index 660ce04..690445d 100644 --- a/lua/nuwiki/install.lua +++ b/lua/nuwiki/install.lua @@ -1,6 +1,6 @@ -- lua/nuwiki/install.lua — install / build the `nuwiki-ls` binary. -- --- Strategy (matches SPEC §7.2): +-- Strategy: -- 1. Look for `bin/nuwiki-ls[.exe]` next to the plugin. -- 2. If missing or `g:nuwiki_build_from_source` is set, build with cargo. -- 3. Otherwise try to download a release asset for the current target. diff --git a/lua/nuwiki/keymaps.lua b/lua/nuwiki/keymaps.lua index d8d0c2c..d10a662 100644 --- a/lua/nuwiki/keymaps.lua +++ b/lua/nuwiki/keymaps.lua @@ -16,7 +16,7 @@ -- wiki_prefix = true, -- ww, ws, wi, … -- } -- --- Mappings that point at §13.1-deferred commands stub out to a +-- Mappings that point at deferred commands stub out to a -- "not yet implemented" notification — better than a silent no-op -- so users get parity *signalling*, not just parity in lhs. @@ -133,12 +133,12 @@ local function open_above_with_bullet() vim.api.nvim_feedkeys('O' .. prefix, 'n', false) end --- ===== Stub for deferred §13.1 commands ===== +-- ===== Stub for deferred commands ===== local function deferred(name) return function() vim.notify( - 'nuwiki: ' .. name .. ' is not yet implemented — see SPEC §13.1', + 'nuwiki: ' .. name .. ' is not yet implemented', vim.log.levels.WARN ) end @@ -218,8 +218,8 @@ function M.attach(bufnr, mappings) map('n', 'gnt', cmd.next_task, { desc = 'nuwiki: next unfinished task' }, bufnr) map('n', 'gln', cmd.cycle_list_item, { desc = 'nuwiki: increment checkbox' }, bufnr) map('x', 'gln', cmd.cycle_list_item, { desc = 'nuwiki: increment checkbox' }, bufnr) - map('n', 'glp', cmd.cycle_list_item, { desc = 'nuwiki: decrement checkbox' }, bufnr) - map('x', 'glp', cmd.cycle_list_item, { desc = 'nuwiki: decrement checkbox' }, bufnr) + map('n', 'glp', cmd.cycle_list_item_back, { desc = 'nuwiki: decrement checkbox' }, bufnr) + map('x', 'glp', cmd.cycle_list_item_back, { desc = 'nuwiki: decrement checkbox' }, bufnr) map('n', 'glx', cmd.reject_list_item, { desc = 'nuwiki: reject checkbox' }, bufnr) map('x', 'glx', cmd.reject_list_item, { desc = 'nuwiki: reject checkbox' }, bufnr) map('n', 'glh', function() cmd.list_change_level(-1, false) end, @@ -235,8 +235,8 @@ function M.attach(bufnr, mappings) map('n', 'gLr', cmd.list_renumber_all, { desc = 'nuwiki: renumber all lists' }, bufnr) map('n', 'gl', cmd.list_remove_done, - { desc = 'nuwiki: remove done items' }, bufnr) - map('n', 'gL', cmd.list_remove_done, + { desc = 'nuwiki: remove done items (current list)' }, bufnr) + map('n', 'gL', cmd.list_remove_done_all, { desc = 'nuwiki: remove done items (whole doc)' }, bufnr) map('n', 'o', open_below_with_bullet, { desc = 'nuwiki: open below + bullet' }, bufnr) map('n', 'O', open_above_with_bullet, { desc = 'nuwiki: open above + bullet' }, bufnr) diff --git a/plugin/nuwiki.vim b/plugin/nuwiki.vim index 840df90..8a2a71e 100644 --- a/plugin/nuwiki.vim +++ b/plugin/nuwiki.vim @@ -4,7 +4,7 @@ " their config (lazy.nvim does this automatically through `opts`). On Vim " we start the LSP client when a vimwiki buffer is loaded. " -" Per SPEC §6.2 / §6.3 this file contains wiring only — all logic lives in +" This file contains wiring only — all logic lives in " the Rust language server. if exists('g:loaded_nuwiki') diff --git a/syntax/vimwiki.vim b/syntax/vimwiki.vim index 635be2f..c9af182 100644 --- a/syntax/vimwiki.vim +++ b/syntax/vimwiki.vim @@ -1,8 +1,8 @@ " syntax/vimwiki.vim — default highlight groups for nuwiki. " " Two responsibilities: -" 1. Default `@vimwiki*` highlight groups for the LSP semantic tokens -" (SPEC §11 P7). Loaded eagerly so the very first hover/highlight +" 1. Default `@vimwiki*` highlight groups for the LSP semantic tokens. +" Loaded eagerly so the very first hover/highlight " cycle after server startup already has colours. " 2. A small regex-based fallback so static highlighting works on " buffers where the LSP isn't running yet. diff --git a/test-personal-wiki.sh b/test-personal-wiki.sh deleted file mode 100755 index ca006a2..0000000 --- a/test-personal-wiki.sh +++ /dev/null @@ -1,34 +0,0 @@ -#!/usr/bin/env bash -# -# test-personal-wiki.sh — open the user's real personal wiki via start-vim.sh. -# -# Reuses start-vim.sh's plumbing (binary build, vim-lsp clone, viminfo -# under the dev cache) but points the wiki root at ~/.vimwiki/personal_wiki -# and disables seed_wiki so we don't drop a scratch `Notes.wiki` into your -# real notes. -# -# Useful when reproducing a user-reported bug against actual content rather -# than the seeded smoke-test wiki. State (viminfo/sessions) still lives -# under $XDG_CACHE_HOME/nuwiki-dev/ so your real Vim install stays untouched. -# -# Usage: -# ./test-personal-wiki.sh # open index.wiki -# ./test-personal-wiki.sh path/to/note.wiki # open a specific file -# NUWIKI_PERSONAL_WIKI=/other/path ./test-personal-wiki.sh -# NUWIKI_DEV_SKIP_BUILD=1 ./test-personal-wiki.sh # reuse the cached binary - -set -euo pipefail - -REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -WIKI_DIR="${NUWIKI_PERSONAL_WIKI:-$HOME/.vimwiki/personal_wiki}" - -if [[ ! -d "$WIKI_DIR" ]]; then - echo "test-personal-wiki: $WIKI_DIR does not exist" >&2 - echo "set NUWIKI_PERSONAL_WIKI to point at your wiki, or create the default path" >&2 - exit 1 -fi - -export NUWIKI_DEV_WIKI="$WIKI_DIR" -export NUWIKI_DEV_NO_SEED=1 - -exec "$REPO_ROOT/start-vim.sh" "$@"