ae7d8c7971
Backfill scope reminder for the audit that surfaced the Phase 14 deferred surface. §13 consolidates the items still owed from earlier phases so a future "what's left" check doesn't have to re-derive scope from the per-phase prose. - §13.1 lists the 11 deferred Phase 14 commands (list/table/link rewriters + colorize), each with an implementation sketch and a suggested cluster (list rewriters, table rewriters, link helpers, Phase-17-blocked). - §13.2 documents the deliberately-deferred `will_rename` / `will_delete` file-operation variants from the Phase 13 backfill. - §13.3 reminds readers that Phases 17–19 are roadmap, not missing-from-earlier-phases. - §13.4 clarifies that §9 syntax checklist `[ ]` rows are parser coverage rather than missing features. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1412 lines
55 KiB
Markdown
1412 lines
55 KiB
Markdown
# 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<BlockNode>
|
||
metadata: PageMetadata -- title, nohtml, template, date
|
||
```
|
||
|
||
#### Block Nodes
|
||
```
|
||
HeadingNode level: 1–6 | children: Vec<InlineNode> | centered: bool
|
||
ParagraphNode children: Vec<InlineNode>
|
||
HorizontalRuleNode
|
||
BlockquoteNode children: Vec<BlockNode>
|
||
PreformattedNode content: String | language: Option<String>
|
||
MathBlockNode content: String | environment: Option<String>
|
||
ListNode ordered: bool | symbol: ListSymbol | items: Vec<ListItemNode>
|
||
DefinitionListNode items: Vec<DefinitionItemNode>
|
||
TableNode rows: Vec<TableRowNode> | has_header: bool
|
||
CommentNode content: String
|
||
ErrorNode raw: String | message: String -- resilient parse failure
|
||
```
|
||
|
||
#### Inline Nodes
|
||
```
|
||
TextNode content: String
|
||
BoldNode children: Vec<InlineNode>
|
||
ItalicNode children: Vec<InlineNode>
|
||
BoldItalicNode children: Vec<InlineNode>
|
||
StrikethroughNode children: Vec<InlineNode>
|
||
CodeNode content: String
|
||
SuperscriptNode children: Vec<InlineNode>
|
||
SubscriptNode children: Vec<InlineNode>
|
||
MathInlineNode content: String
|
||
KeywordNode keyword: Keyword -- TODO|DONE|STARTED|FIXME|FIXED|XXX
|
||
ColorNode color: String | children: Vec<InlineNode>
|
||
WikiLinkNode target: LinkTarget | description: Option<Vec<InlineNode>>
|
||
ExternalLinkNode url: String | description: Option<Vec<InlineNode>>
|
||
TransclusionNode url: String | alt: Option<String> | attrs: HashMap<String,String>
|
||
RawUrlNode url: String
|
||
```
|
||
|
||
#### Supporting Types
|
||
```
|
||
ListItemNode
|
||
symbol: ListSymbol
|
||
level: usize
|
||
checkbox: Option<CheckboxState>
|
||
children: Vec<InlineNode>
|
||
sublist: Option<ListNode>
|
||
|
||
DefinitionItemNode
|
||
term: Option<Vec<InlineNode>>
|
||
definitions: Vec<Vec<InlineNode>>
|
||
|
||
TableRowNode cells: Vec<TableCellNode> | is_header: bool
|
||
TableCellNode children: Vec<InlineNode> | col_span: bool | row_span: bool
|
||
|
||
LinkTarget
|
||
kind: LinkKind -- Wiki|Interwiki|Diary|File|Local|Raw|AnchorOnly
|
||
path: Option<String>
|
||
wiki_index: Option<usize>
|
||
wiki_name: Option<String>
|
||
anchor: Option<String>
|
||
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<Token>` 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<DashMap<Url, DocumentState>>
|
||
|
||
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 <text>`
|
||
- [ ] `%nohtml`
|
||
- [ ] `%template <name>`
|
||
- [ ] `%date <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 `<C-Space>`. |
|
||
| 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 `<wiki_root>/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<RwLock<Config>>` field on
|
||
`Backend`.
|
||
|
||
```rust
|
||
pub struct Config {
|
||
pub log_level: LogLevel,
|
||
pub diagnostic: DiagnosticConfig,
|
||
pub wikis: Vec<WikiConfig>, // 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<Url, Vec<TextEdit>>,
|
||
file_ops: Vec<DocumentChangeOperation>,
|
||
}
|
||
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<DocSnapshot>;
|
||
}
|
||
```
|
||
|
||
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<Diagnostic>;
|
||
```
|
||
|
||
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<RwLock<WorkspaceIndex>>,
|
||
}
|
||
|
||
struct Backend {
|
||
// …
|
||
wikis: Arc<RwLock<Vec<Wiki>>>,
|
||
}
|
||
|
||
impl Backend {
|
||
fn resolve_uri_to_wiki(&self, uri: &Url) -> Option<WikiId>; // longest-prefix root match
|
||
fn wiki(&self, id: WikiId) -> Option<Arc<Wiki>>;
|
||
fn default_wiki(&self) -> Option<Arc<Wiki>>;
|
||
}
|
||
```
|
||
|
||
`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<String>
|
||
scope: TagScope -- File | Heading(idx) | Standalone
|
||
|
||
PageMetadata (extend)
|
||
tags: Vec<String> -- 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>
|
||
|
||
TagInfo
|
||
name: String
|
||
scope: TagScope
|
||
span: Span
|
||
```
|
||
|
||
Plus a `tags_by_name: HashMap<String, Vec<(Url, Span)>>` 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<BrokenLink>` 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
|
||
|
||
```
|
||
<wiki_root>/<diary_rel_path>/ # 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` | `<wiki_root>/_html` | output directory |
|
||
| `template_path` | `<wiki_root>/_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<DashMap<WikiId, Arc<RwLock<WorkspaceIndex>>>>
|
||
```
|
||
|
||
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 `<C-o>` (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:
|
||
|
||
- `<Leader>ww` / `<Leader>wt` / `<Leader>ws` / `<Leader>wi` /
|
||
`<Leader>w<Leader>w` etc. — wiki navigation
|
||
- `<CR>` / `<S-CR>` / `<C-CR>` / `<M-CR>` / `<C-S-CR>` — link follow variants
|
||
- `<Backspace>` / `<Tab>` / `<S-Tab>` — 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`
|
||
- `<C-Space>` — `nuwiki.list.toggleCheckbox`
|
||
- `gnt` — `nuwiki.list.nextTask`
|
||
- `gl<Space>` / `gL<Space>` — 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
|
||
- `<A-Left>` / `<A-Right>` — table move column
|
||
- `<C-Up>` / `<C-Down>` — diary prev/next day
|
||
- `<Leader>wh` / `<Leader>whh` — HTML export current / browse
|
||
- `<Leader>wc` — colorize (asks for colour)
|
||
- `<Leader>wn` — goto/create new page
|
||
- `<Leader>wd` / `<Leader>wr` — delete / rename current page
|
||
- Mouse: `<2-LeftMouse>`, `<S-2-LeftMouse>`, `<C-2-LeftMouse>`,
|
||
`<RightMouse><LeftMouse>` (opt-in via `g:nuwiki_mouse_mappings`)
|
||
|
||
#### Insert-mode behaviour
|
||
|
||
- Table cells: `<CR>`/`<Tab>`/`<S-Tab>` navigate cells, create rows
|
||
on overflow; implemented in Lua/VimL via a small autocmd
|
||
- Lists: `<CR>` inserts the next bullet/number; `<S-CR>` continues without
|
||
a new bullet; `<C-T>`/`<C-D>` indent/dedent; `<C-L><C-J>`/`<C-L><C-K>`
|
||
cycle the list symbol; `<C-L><C-M>` 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
|
||
|
||
Phase 14 shipped a focused subset (checkbox toggle/cycle/reject,
|
||
nextTask navigation, heading promote/demote). The structural-rewriter
|
||
commands below were deferred — each needs its own AST-aware rewriter,
|
||
and they don't share enough scaffolding to land together cheaply.
|
||
|
||
| Command | SPEC | What it does | Sketch |
|
||
|---|---|---|---|
|
||
| `nuwiki.list.changeSymbol` | §12.5 | Rewrite list marker (`-` → `*` → `#` → `1.` …), single item or whole list (`whole_list: bool` arg). | Locate item span via existing `find_list_item_at`; rewrite the marker token; if `whole_list` and numeric, renumber. |
|
||
| `nuwiki.list.changeLevel` | §12.5 | Indent/dedent one item or a subtree (`delta: i32`, `whole_subtree: bool`). | Compute leading-whitespace delta per line; re-indent each line of the item span; if subtree, descend into `ListItemNode.sublist`. |
|
||
| `nuwiki.list.renumber` | §12.5 | Re-sequence numeric markers across a list or a whole file (`whole_file: bool`). | Walk every `ListNode` whose `symbol` is `Numeric`/`NumericParen`/`AlphaParen`/…; rewrite the marker substrings in source order. |
|
||
| `nuwiki.list.removeDone` | §12.5 | Strip `[X]` / `[-]` items and their checked children. | Recursive list walker emitting delete spans for matching items; also delete the trailing newline so the surrounding list stays valid. |
|
||
| `nuwiki.table.align` | §12.5 | Reformat columns to max width. | Two-pass: width-per-column scan, then re-render every row with padding; respect `>` (col span) and `\/` (row span) markers. |
|
||
| `nuwiki.table.moveColumn` | §12.5 | Swap column with neighbour (`dir: "left" \| "right"`). | Locate `TableNode` under cursor; for each row, swap `cells[col]` and `cells[col±1]`; re-emit. |
|
||
| `nuwiki.table.insert` | §12.5 | Insert blank table at cursor (`cols`, `rows`). | Pure string templating; align outputs to current indentation. |
|
||
| `nuwiki.link.normalize` | §12.5 | Convert word/selection to `[[wikilink]]`, add description if missing. | Inspect cursor selection; if a plain word, wrap in `[[…]]`; if already a wikilink, ensure `\|description` is present. |
|
||
| `nuwiki.link.pasteWikilink` | §12.5 | Paste current page name as an absolute wikilink. | One-line insert; page name from `index::page_name_from_uri`. |
|
||
| `nuwiki.link.pasteUrl` | §12.5 | Paste the corresponding HTML output URL. | Blocked on Phase 17 (`html_path` / `html_filename_parameterization` config). |
|
||
| `nuwiki.colorize` | §12.5 | Wrap range in colour tag per `color_tag_template`. | Blocked on Phase 17 (`color_dic` config) — until then the template isn't reachable. |
|
||
|
||
#### Estimated sequencing
|
||
|
||
- **Cluster A (list rewriters)** — `changeSymbol`, `changeLevel`,
|
||
`renumber`, `removeDone` share a list-walker pattern. Likely one
|
||
PR with a shared `ops::list_walker` helper and four command handlers.
|
||
- **Cluster B (table rewriters)** — `align`, `moveColumn`, `insert`
|
||
share a column-width pass and a row-by-row re-emitter. Likely a
|
||
second PR.
|
||
- **Cluster C (link helpers)** — `normalize` and `pasteWikilink` are
|
||
small and don't share much; can land independently.
|
||
- **Phase-17-blocked** — `pasteUrl` and `colorize` wait until the
|
||
HTML export commands land the necessary config keys.
|
||
|
||
### 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 phases 17–19
|
||
|
||
Tracked in §10 with status `⏳`. Not "missing" from earlier phases —
|
||
they're future work in the v1.1 roadmap:
|
||
|
||
- **Phase 17** — HTML export commands (`nuwiki.export.*`), template
|
||
resolution, CSS file management, auto-export. Brings in
|
||
`html_path` / `template_path` / `color_dic` / etc. config keys.
|
||
- **Phase 18** — Multi-wiki. Data structures (Wiki aggregate,
|
||
per-wiki index) already landed in Phase 11; this phase is the
|
||
config-shape change and the picker commands.
|
||
- **Phase 19** — Editor glue v2: `:Vimwiki*` compat layer, default
|
||
keymaps, text objects, folding (P14 already resolved in favour of
|
||
LSP `foldingRange` + `foldexpr` fallback).
|
||
|
||
### 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.
|
||
|
||
---
|
||
|