Major clean up and improvements to vimwiki compatibility and documentation.
Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
@@ -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/<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.
|
||||
@@ -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-<name>"`.
|
||||
|
||||
---
|
||||
|
||||
## 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<DashMap<Url, DocumentState>>`
|
||||
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** `<Leader>ww`/`wt`/`ws`/`wi`, `<Leader>w<Leader>w|y|t|m|i`,
|
||||
`<Leader>wn`/`wd`/`wr`/`wc`
|
||||
- **Links** `<CR>` (follow / wrap-word), `<S-CR>`/`<C-CR>`/`<C-S-CR>`
|
||||
(split/vsplit/tab), `<BS>` (back), `<Tab>`/`<S-Tab>` (next/prev link), `+`
|
||||
(wrap as wikilink)
|
||||
- **Lists** `<C-Space>` (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<Space>`/`gL<Space>`
|
||||
(remove done, list/whole-doc), `o`/`O` (open with bullet); insert-mode
|
||||
`<C-T>`/`<C-D>`, `<C-L><C-J>`/`<C-L><C-K>`, `<C-L><C-M>`; smart `<CR>`/`<Tab>`
|
||||
- **Headers** `=`/`-` (deeper/shallower), `]]`/`[[` (next/prev), `]=`/`[=`
|
||||
(sibling), `]u`/`[u` (parent) — pure VimL/Lua, no LSP round-trip
|
||||
- **Tables** `gqq`/`gww` (align), `<A-Left>`/`<A-Right>` (move column)
|
||||
- **Diary** `<C-Down>`/`<C-Up>` (next/prev entry)
|
||||
- **HTML** `<Leader>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.
|
||||
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>nuwiki Architecture Diagram</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
background: #020617;
|
||||
min-height: 100vh;
|
||||
padding: 2rem;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.header-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.pulse-dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: #22d3ee;
|
||||
border-radius: 50%;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #94a3b8;
|
||||
font-size: 0.875rem;
|
||||
margin-left: 1.75rem;
|
||||
}
|
||||
|
||||
.diagram-container {
|
||||
background: rgba(15, 23, 42, 0.5);
|
||||
border-radius: 1rem;
|
||||
border: 1px solid #1e293b;
|
||||
padding: 1.5rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 100%;
|
||||
min-width: 900px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: rgba(15, 23, 42, 0.5);
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid #1e293b;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.card-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.card-dot.cyan { background: #22d3ee; }
|
||||
.card-dot.emerald { background: #34d399; }
|
||||
.card-dot.violet { background: #a78bfa; }
|
||||
.card-dot.amber { background: #fbbf24; }
|
||||
.card-dot.rose { background: #fb7185; }
|
||||
|
||||
.card h3 {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.card ul {
|
||||
list-style: none;
|
||||
color: #94a3b8;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.card li {
|
||||
margin-bottom: 0.375rem;
|
||||
}
|
||||
|
||||
.footer {
|
||||
text-align: center;
|
||||
margin-top: 1.5rem;
|
||||
color: #475569;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<!-- Header -->
|
||||
<div class="header">
|
||||
<div class="header-row">
|
||||
<div class="pulse-dot"></div>
|
||||
<h1>nuwiki Architecture</h1>
|
||||
</div>
|
||||
<p class="subtitle">Layered architecture showing the Rust LSP backend and editor integration layers</p>
|
||||
</div>
|
||||
|
||||
<!-- Main Diagram -->
|
||||
<div class="diagram-container">
|
||||
<svg viewBox="0 0 1000 720">
|
||||
<!-- Definitions -->
|
||||
<defs>
|
||||
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
||||
<polygon points="0 0, 10 3.5, 0 7" fill="#64748b" />
|
||||
</marker>
|
||||
<pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
|
||||
<path d="M 40 0 L 0 0 0 40" fill="none" stroke="#1e293b" stroke-width="0.5"/>
|
||||
</pattern>
|
||||
</defs>
|
||||
|
||||
<!-- Background Grid -->
|
||||
<rect width="100%" height="100%" fill="url(#grid)" />
|
||||
|
||||
<!-- Layer Model from SPEC.md -->
|
||||
<!-- Consumer Layer -->
|
||||
<rect x="50" y="60" width="400" height="80" rx="6" fill="rgba(8, 51, 68, 0.4)" stroke="#22d3ee" stroke-width="1.5"/>
|
||||
<text x="250" y="95" fill="white" font-size="12" font-weight="600" text-anchor="middle">Consumer Layer</text>
|
||||
<text x="250" y="115" fill="#94a3b8" font-size="9" text-anchor="middle">Editor highlight, HTML export, TOC, etc.</text>
|
||||
|
||||
<!-- AST Layer -->
|
||||
<rect x="50" y="180" width="400" height="80" rx="6" fill="rgba(8, 51, 68, 0.4)" stroke="#22d3ee" stroke-width="1.5"/>
|
||||
<text x="250" y="215" fill="white" font-size="12" font-weight="600" text-anchor="middle">AST Layer (nuwiki-core)</text>
|
||||
<text x="250" y="235" fill="#94a3b8" font-size="9" text-anchor="middle">Document > Block nodes > Inline nodes</text>
|
||||
|
||||
<!-- Parser Layer -->
|
||||
<rect x="50" y="300" width="400" height="80" rx="6" fill="rgba(8, 51, 68, 0.4)" stroke="#22d3ee" stroke-width="1.5"/>
|
||||
<text x="250" y="335" fill="white" font-size="12" font-weight="600" text-anchor="middle">Parser Layer</text>
|
||||
<text x="250" y="355" fill="#94a3b8" font-size="9" text-anchor="middle">Syntax-specific (nuwiki-core)</text>
|
||||
|
||||
<!-- Lexer Layer -->
|
||||
<rect x="50" y="420" width="400" height="80" rx="6" fill="rgba(8, 51, 68, 0.4)" stroke="#22d3ee" stroke-width="1.5"/>
|
||||
<text x="250" y="455" fill="white" font-size="12" font-weight="600" text-anchor="middle">Lexer Layer</text>
|
||||
<text x="250" y="475" fill="#94a3b8" font-size="9" text-anchor="middle">Syntax-specific (nuwiki-core)</text>
|
||||
|
||||
<!-- Raw Text Input -->
|
||||
<rect x="50" y="540" width="400" height="60" rx="6" fill="rgba(8, 51, 68, 0.4)" stroke="#22d3ee" stroke-width="1.5"/>
|
||||
<text x="250" y="575" fill="white" font-size="12" font-weight="600" text-anchor="middle">Raw Text Input</text>
|
||||
|
||||
<!-- Arrows showing data flow downward -->
|
||||
<line x1="250" y1="140" x2="250" y2="180" stroke="#22d3ee" stroke-width="1.5" marker-end="url(#arrowhead)"/>
|
||||
<line x1="250" y1="260" x2="250" y2="300" stroke="#22d3ee" stroke-width="1.5" marker-end="url(#arrowhead)"/>
|
||||
<line x1="250" y1="380" x2="250" y2="420" stroke="#22d3ee" stroke-width="1.5" marker-end="url(#arrowhead)"/>
|
||||
<line x1="250" y1="500" x2="250" y2="540" stroke="#22d3ee" stroke-width="1.5" marker-end="url(#arrowhead)"/>
|
||||
|
||||
<!-- Crate Dependency Rules (right side) -->
|
||||
<!-- nuwiki-ls -->
|
||||
<rect x="550" y="100" width="180" height="60" rx="6" fill="rgba(6, 78, 59, 0.4)" stroke="#34d399" stroke-width="1.5"/>
|
||||
<text x="640" y="130" fill="white" font-size="11" font-weight="600" text-anchor="middle">nuwiki-ls</text>
|
||||
<text x="640" y="145" fill="#94a3b8" font-size="8" text-anchor="middle">Binary crate — starts stdio LSP server</text>
|
||||
|
||||
<!-- nuwiki-lsp -->
|
||||
<rect x="550" y="200" width="180" height="60" rx="6" fill="rgba(6, 78, 59, 0.4)" stroke="#34d399" stroke-width="1.5"/>
|
||||
<text x="640" y="230" fill="white" font-size="11" font-weight="600" text-anchor="middle">nuwiki-lsp</text>
|
||||
<text x="640" y="245" fill="#94a3b8" font-size="8" text-anchor="middle">LSP protocol bridge (tower-lsp)</text>
|
||||
|
||||
<!-- nuwiki-core -->
|
||||
<rect x="550" y="300" width="180" height="60" rx="6" fill="rgba(6, 78, 59, 0.4)" stroke="#34d399" stroke-width="1.5"/>
|
||||
<text x="640" y="330" fill="white" font-size="11" font-weight="600" text-anchor="middle">nuwiki-core</text>
|
||||
<text x="640" y="345" fill="#94a3b8" font-size="8" text-anchor="middle">Parser, AST, Renderer (editor-agnostic)</text>
|
||||
|
||||
<!-- Dependencies arrows -->
|
||||
<line x1="730" y1="130" x2="730" y2="170" stroke="#34d399" stroke-width="1.5" marker-end="url(#arrowhead)"/>
|
||||
<line x1="730" y1="230" x2="730" y2="270" stroke="#34d399" stroke-width="1.5" marker-end="url(#arrowhead)"/>
|
||||
|
||||
<!-- Dependency labels -->
|
||||
<text x="750" y="150" fill="#94a3b8" font-size="8">depends on</text>
|
||||
<text x="750" y="250" fill="#94a3b8" font-size="8">depends on</text>
|
||||
|
||||
<!-- Editor Integration Layers -->
|
||||
<!-- VimL Layer -->
|
||||
<rect x="50" y="620" width="250" height="60" rx="6" fill="rgba(76, 29, 149, 0.4)" stroke="#a78bfa" stroke-width="1.5"/>
|
||||
<text x="175" y="648" fill="white" font-size="10" font-weight="600" text-anchor="middle">VimL Layer</text>
|
||||
<text x="175" y="663" fill="#94a3b8" font-size="8" text-anchor="middle">plugin/nuwiki.vim, autoload/</text>
|
||||
|
||||
<!-- Lua Layer (Neovim) -->
|
||||
<rect x="350" y="620" width="250" height="60" rx="6" fill="rgba(76, 29, 149, 0.4)" stroke="#a78bfa" stroke-width="1.5"/>
|
||||
<text x="475" y="648" fill="white" font-size="10" font-weight="600" text-anchor="middle">Lua Layer</text>
|
||||
<text x="475" y="663" fill="#94a3b8" font-size="8" text-anchor="middle">lua/nuwiki/</text>
|
||||
|
||||
<!-- Editor arrows from layers to LSP server -->
|
||||
<line x1="175" y1="680" x2="250" y2="600" stroke="#a78bfa" stroke-width="1.5" marker-end="url(#arrowhead)"/>
|
||||
<line x1="475" y1="680" x2="250" y2="600" stroke="#a78bfa" stroke-width="1.5" marker-end="url(#arrowhead)"/>
|
||||
|
||||
<!-- Labels for editor connections -->
|
||||
<text x="100" y="695" fill="#94a3b8" font-size="8">Vim/Neovim</text>
|
||||
<text x="400" y="695" fill="#94a3b8" font-size="8">Neovim only</text>
|
||||
|
||||
<!-- LSP Features Section -->
|
||||
<rect x="750" y="100" width="200" height="200" rx="8" fill="rgba(120, 53, 15, 0.3)" stroke="#fbbf24" stroke-width="1.5"/>
|
||||
<text x="850" y="125" fill="white" font-size="11" font-weight="600" text-anchor="middle">LSP Features</text>
|
||||
<text x="760" y="145" fill="#94a3b8" font-size="8">• textDocument/semanticTokens</text>
|
||||
<text x="760" y="158" fill="#94a3b8" font-size="8">• textDocument/publishDiagnostics</text>
|
||||
<text x="760" y="171" fill="#94a3b8" font-size="8">• textDocument/definition</text>
|
||||
<text x="760" y="184" fill="#94a3b8" font-size="8">• textDocument/references</text>
|
||||
<text x="760" y="197" fill="#94a3b8" font-size="8">• textDocument/documentSymbol</text>
|
||||
<text x="760" y="210" fill="#94a3b8" font-size="8">• textDocument/hover</text>
|
||||
<text x="760" y="223" fill="#94a3b8" font-size="8">• textDocument/completion</text>
|
||||
<text x="760" y="236" fill="#94a3b8" font-size="8">• workspace/rename</text>
|
||||
<text x="760" y="249" fill="#94a3b8" font-size="8">• workspace/symbol</text>
|
||||
|
||||
<!-- Arrow from LSP layer to LSP Features -->
|
||||
<line x1="730" y1="260" x2="750" y2="200" stroke="#fbbf24" stroke-width="1.5" marker-end="url(#arrowhead)"/>
|
||||
|
||||
<!-- Security Boundary/Crate Dependencies -->
|
||||
<rect x="540" y="80" width="200" height="300" rx="12" fill="rgba(251, 191, 36, 0.05)" stroke="#fbbf24" stroke-width="1" stroke-dasharray="8,4"/>
|
||||
<text x="552" y="98" fill="#fbbf24" font-size="9" font-weight="600">Crate Dependencies</text>
|
||||
|
||||
<!-- Legend -->
|
||||
<text x="750" y="350" fill="white" font-size="10" font-weight="600">Legend</text>
|
||||
|
||||
<rect x="750" y="362" width="16" height="10" rx="2" fill="rgba(8, 51, 68, 0.4)" stroke="#22d3ee" stroke-width="1"/>
|
||||
<text x="772" y="370" fill="#94a3b8" font-size="8">Editor Layers</text>
|
||||
|
||||
<rect x="750" y="382" width="16" height="10" rx="2" fill="rgba(6, 78, 59, 0.4)" stroke="#34d399" stroke-width="1"/>
|
||||
<text x="772" y="390" fill="#94a3b8" font-size="8">Core Crates</text>
|
||||
|
||||
<rect x="750" y="402" width="16" height="10" rx="2" fill="rgba(76, 29, 149, 0.4)" stroke="#a78bfa" stroke-width="1"/>
|
||||
<text x="772" y="410" fill="#94a3b8" font-size="8">Layer Boundaries</text>
|
||||
|
||||
<line x1="750" y1="425" x2="766" y2="425" stroke="#fbbf24" stroke-width="1" stroke-dasharray="3,3"/>
|
||||
<text x="772" y="433" fill="#94a3b8" font-size="8">Data Flow</text>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Info Cards -->
|
||||
<div class="cards">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="card-dot cyan"></div>
|
||||
<h3>Editor Integration</h3>
|
||||
</div>
|
||||
<ul>
|
||||
<li>• VimL layer for universal Vim support</li>
|
||||
<li>• Lua layer for Neovim integration</li>
|
||||
<li>• Zero-logic editor layers (pure wiring only)</li>
|
||||
<li>• Supports vim-lsp and coc.nvim for Vim</li>
|
||||
<li>• Built-in LSP client for Neovim 0.11+</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="card-dot emerald"></div>
|
||||
<h3>Core Architecture</h3>
|
||||
</div>
|
||||
<ul>
|
||||
<li>• Strict layer separation (no circular deps)</li>
|
||||
<li>• nuwiki-core is editor-agnostic</li>
|
||||
<li>• LSP bridge uses tower-lsp</li>
|
||||
<li>• Binary crate is intentionally thin</li>
|
||||
<li>• Strict dependency rules enforced</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="card-dot violet"></div>
|
||||
<h3>LSP Features</h3>
|
||||
</div>
|
||||
<ul>
|
||||
<li>• Syntax highlighting (semantic tokens)</li>
|
||||
<li>• Diagnostics (broken links, parse errors)</li>
|
||||
<li>• Go-to-definition and references</li>
|
||||
<li>• Document symbols (TOC/outline)</li>
|
||||
<li>• Hover, completion, workspace symbols</li>
|
||||
<li>• Rename and workspace-wide operations</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<p class="footer">
|
||||
nuwiki • Rust-based vimwiki replacement with LSP backend
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Executable
+91
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# start-nvim.sh — launch Neovim with a minimal config that loads nuwiki.
|
||||
#
|
||||
# Builds the language server (release mode), symlinks it into `bin/` so
|
||||
# the plugin's default install path resolves, then spawns Neovim with
|
||||
# `--clean` and a generated `init.lua` that:
|
||||
# * adds this repo to `runtimepath`
|
||||
# * configures wiki_root to a scratch directory under
|
||||
# $XDG_CACHE_HOME/nuwiki-dev/wiki
|
||||
# * calls `require('nuwiki').setup({...})`
|
||||
#
|
||||
# Usage: ./development/start-nvim.sh [extra args...]
|
||||
# ./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,
|
||||
# so this is a fast no-op when nothing changed. Set
|
||||
# NUWIKI_DEV_SKIP_BUILD=1 to skip the build step entirely.
|
||||
#
|
||||
# Tested with Neovim 0.10+. State (cache/data/config) lives under
|
||||
# $XDG_CACHE_HOME/nuwiki-dev/ so your real Neovim install stays
|
||||
# untouched.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# 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"
|
||||
|
||||
write_init() {
|
||||
mkdir -p "$DEV_DIR"
|
||||
cat > "$INIT_FILE" <<EOF
|
||||
-- start-nvim.sh: generated minimal config for nuwiki development.
|
||||
-- Regenerated on every launch; edit start-nvim.sh, not this file.
|
||||
|
||||
-- Confine state to the dev cache so we never touch the user's real
|
||||
-- Neovim install.
|
||||
vim.env.XDG_STATE_HOME = '$STATE_DIR'
|
||||
vim.env.XDG_DATA_HOME = '$DATA_DIR'
|
||||
vim.env.XDG_CONFIG_HOME = '$CONFIG_DIR'
|
||||
|
||||
vim.opt.runtimepath:prepend('$REPO_ROOT')
|
||||
|
||||
vim.g.nuwiki_binary_path = '$REPO_ROOT/bin/nuwiki-ls'
|
||||
|
||||
-- Friendly defaults for an interactive smoke test.
|
||||
vim.opt.number = true
|
||||
vim.opt.signcolumn = 'yes'
|
||||
vim.opt.termguicolors = true
|
||||
vim.opt.swapfile = false
|
||||
vim.opt.writebackup = false
|
||||
vim.opt.undofile = false
|
||||
vim.g.mapleader = ' '
|
||||
|
||||
require('nuwiki').setup({
|
||||
wiki_root = '$WIKI_DIR',
|
||||
})
|
||||
|
||||
-- Surface log lines (the LSP logs via window/logMessage). Without this
|
||||
-- they're swallowed; with it they land in :messages.
|
||||
vim.lsp.set_log_level('info')
|
||||
|
||||
print('[nuwiki-dev] ready — wiki root: $WIKI_DIR')
|
||||
print('[nuwiki-dev] :LspInfo to inspect, :checkhealth nuwiki for diagnostics')
|
||||
EOF
|
||||
}
|
||||
|
||||
main() {
|
||||
ensure_binary
|
||||
seed_wiki
|
||||
write_init
|
||||
mkdir -p "$STATE_DIR" "$DATA_DIR" "$CONFIG_DIR"
|
||||
|
||||
log "launching: nvim --clean -u $INIT_FILE"
|
||||
if [[ $# -gt 0 ]]; then
|
||||
exec nvim --clean -u "$INIT_FILE" "$@"
|
||||
else
|
||||
exec nvim --clean -u "$INIT_FILE" "$WIKI_DIR/index.wiki"
|
||||
fi
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Executable
+132
@@ -0,0 +1,132 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# start-vim.sh — launch Vim with a minimal config that loads nuwiki.
|
||||
#
|
||||
# The plugin's Vim path (autoload/nuwiki/lsp.vim) prefers `vim-lsp` and
|
||||
# falls back to `coc.nvim`. This script clones vim-lsp + async.vim into
|
||||
# the dev cache on first run so the LSP integration is exercisable
|
||||
# without polluting your real Vim install.
|
||||
#
|
||||
# Usage: ./development/start-vim.sh [extra args...]
|
||||
# ./development/start-vim.sh # open the scratch wiki's index
|
||||
# ./development/start-vim.sh path/to/note.wiki # open a specific file
|
||||
# NUWIKI_DEV_WIKI=/tmp/foo ./development/start-vim.sh
|
||||
# NUWIKI_DEV_NO_LSP=1 ./development/start-vim.sh # skip vim-lsp setup
|
||||
# NUWIKI_DEV_NO_SEED=1 ./development/start-vim.sh # use WIKI_DIR as-is (no scratch seeding)
|
||||
# NUWIKI_DEV_SKIP_BUILD=1 ./development/start-vim.sh # reuse the cached binary
|
||||
#
|
||||
# The release binary is rebuilt on every launch so source changes
|
||||
# always reach the running LSP. `cargo build --release` is incremental,
|
||||
# so this is a fast no-op when nothing changed. Set
|
||||
# NUWIKI_DEV_SKIP_BUILD=1 to skip the build step entirely.
|
||||
#
|
||||
# Tested with Vim 9.1+. State (viminfo/sessions) lives under
|
||||
# $XDG_CACHE_HOME/nuwiki-dev/ so your real Vim install stays untouched.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Shared paths + helpers (log, ensure_binary, write_sample_wiki, seed_wiki).
|
||||
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/_common.sh"
|
||||
|
||||
VIMRC="$DEV_DIR/vimrc"
|
||||
VIM_STATE="$DEV_DIR/vim-state"
|
||||
VIM_LSP_DIR="$DEV_DIR/vim-lsp"
|
||||
ASYNC_DIR="$DEV_DIR/async.vim"
|
||||
|
||||
clone_if_missing() {
|
||||
local repo_url="$1"
|
||||
local dest="$2"
|
||||
if [[ -d "$dest/.git" ]]; then
|
||||
return
|
||||
fi
|
||||
if ! command -v git >/dev/null 2>&1; then
|
||||
log "git not found — skipping $repo_url"
|
||||
return 1
|
||||
fi
|
||||
log "cloning $repo_url → $dest"
|
||||
git clone --depth 1 "$repo_url" "$dest" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
ensure_vim_lsp() {
|
||||
if [[ "${NUWIKI_DEV_NO_LSP:-0}" == "1" ]]; then
|
||||
log "NUWIKI_DEV_NO_LSP=1 — skipping vim-lsp setup"
|
||||
return
|
||||
fi
|
||||
mkdir -p "$DEV_DIR"
|
||||
clone_if_missing https://github.com/prabirshrestha/vim-lsp.git "$VIM_LSP_DIR" || true
|
||||
clone_if_missing https://github.com/prabirshrestha/async.vim.git "$ASYNC_DIR" || true
|
||||
}
|
||||
|
||||
write_vimrc() {
|
||||
mkdir -p "$DEV_DIR"
|
||||
cat > "$VIMRC" <<EOF
|
||||
" start-vim.sh: generated minimal config for nuwiki development.
|
||||
" Regenerated on every launch; edit start-vim.sh, not this file.
|
||||
|
||||
" Confine state to the dev cache so we never touch the user's real Vim
|
||||
" install.
|
||||
set viminfo='100,n$VIM_STATE/viminfo
|
||||
let &directory = '$VIM_STATE/swap//'
|
||||
let &backupdir = '$VIM_STATE/backup//'
|
||||
let &undodir = '$VIM_STATE/undo//'
|
||||
|
||||
" Make Vim look for runtime paths in the dev cache.
|
||||
set nocompatible
|
||||
let &runtimepath = '$REPO_ROOT' . ',' . &runtimepath
|
||||
let &runtimepath .= ',$VIM_LSP_DIR'
|
||||
let &runtimepath .= ',$ASYNC_DIR'
|
||||
|
||||
let g:nuwiki_binary_path = '$REPO_ROOT/bin/nuwiki-ls'
|
||||
let g:nuwiki_wiki_root = '$WIKI_DIR'
|
||||
let g:nuwiki_file_extension = '.wiki'
|
||||
let g:nuwiki_syntax = 'vimwiki'
|
||||
let g:nuwiki_log_level = 'info'
|
||||
|
||||
filetype plugin indent on
|
||||
syntax enable
|
||||
set hidden
|
||||
set termguicolors
|
||||
set number
|
||||
set signcolumn=yes
|
||||
set noswapfile
|
||||
set nobackup
|
||||
set nowritebackup
|
||||
let mapleader = ' '
|
||||
|
||||
" Auto-detect filetype for .wiki files (the plugin ships ftdetect, but
|
||||
" --clean disables those — copy the rule here).
|
||||
augroup NuwikiFtdetect
|
||||
autocmd!
|
||||
autocmd BufRead,BufNewFile *.wiki setfiletype vimwiki
|
||||
augroup END
|
||||
|
||||
" If vim-lsp is on the runtimepath, the plugin's autoload entry point
|
||||
" registers the server automatically. Triggering it on FileType keeps
|
||||
" the script's responsibility minimal.
|
||||
augroup NuwikiStart
|
||||
autocmd!
|
||||
autocmd FileType vimwiki call nuwiki#lsp#start()
|
||||
augroup END
|
||||
|
||||
" Status messages go to :messages so they don't trigger "Press ENTER".
|
||||
echomsg '[nuwiki-dev] ready — wiki root: $WIKI_DIR'
|
||||
echomsg '[nuwiki-dev] :LspStatus for diagnostics, :messages for log'
|
||||
EOF
|
||||
}
|
||||
|
||||
main() {
|
||||
ensure_binary
|
||||
ensure_vim_lsp
|
||||
seed_wiki
|
||||
write_vimrc
|
||||
mkdir -p "$VIM_STATE/swap" "$VIM_STATE/backup" "$VIM_STATE/undo"
|
||||
|
||||
log "launching: vim --clean -u $VIMRC"
|
||||
if [[ $# -gt 0 ]]; then
|
||||
exec vim --clean -u "$VIMRC" "$@"
|
||||
else
|
||||
exec vim --clean -u "$VIMRC" "$WIKI_DIR/index.wiki"
|
||||
fi
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -0,0 +1,75 @@
|
||||
" development/syntax-diag.vim — dump highlighting state to /tmp/nuwiki-syndiag.log
|
||||
"
|
||||
" Usage: open a .wiki file with VARIED markup (heading, *bold*, _italic_,
|
||||
" `code`), then:
|
||||
" :source development/syntax-diag.vim
|
||||
" Then paste /tmp/nuwiki-syndiag.log back.
|
||||
|
||||
redir! > /tmp/nuwiki-syndiag.log
|
||||
|
||||
echo '=== state ==='
|
||||
echo 'filetype=' . &filetype
|
||||
echo 'syntax=' . &syntax
|
||||
echo 'b:current_syntax=' . get(b:, 'current_syntax', '<unset>')
|
||||
echo 'syntax_on=' . exists('g:syntax_on')
|
||||
echo 'colors_name=' . get(g:, 'colors_name', '<unset>')
|
||||
echo 'background=' . &background
|
||||
echo 'termguicolors=' . &termguicolors
|
||||
echo 't_Co=' . &t_Co
|
||||
echo 'has(nvim)=' . has('nvim')
|
||||
echo 'g:loaded_nuwiki=' . get(g:, 'loaded_nuwiki', '<unset>')
|
||||
echo 'g:loaded_vimwiki=' . get(g:, 'loaded_vimwiki', '<unset>')
|
||||
|
||||
echo ''
|
||||
echo '=== every syntax/vimwiki.vim in runtimepath (first wins) ==='
|
||||
for s:p in split(&runtimepath, ',')
|
||||
let s:f = s:p . '/syntax/vimwiki.vim'
|
||||
if filereadable(s:f)
|
||||
echo s:f
|
||||
endif
|
||||
endfor
|
||||
|
||||
echo ''
|
||||
echo '=== :hi for our groups (NO silent — capture into redir) ==='
|
||||
for s:g in ['nuwikiHeading', 'nuwikiBold', 'nuwikiItalic', 'nuwikiCode',
|
||||
\ 'nuwikiPlaceholder', 'nuwikiCommentLine', 'nuwikiWikilink',
|
||||
\ 'Title', 'PreProc', 'String', 'Comment', 'Underlined', 'Normal']
|
||||
execute 'highlight ' . s:g
|
||||
endfor
|
||||
|
||||
echo ''
|
||||
echo '=== synID walk over current buffer (first 30 lines) ==='
|
||||
for s:ln in range(1, min([line('$'), 30]))
|
||||
let s:l = getline(s:ln)
|
||||
let s:segs = []
|
||||
let s:last = ''
|
||||
let s:start = 1
|
||||
for s:c in range(1, len(s:l) + 1)
|
||||
let s:g = s:c <= len(s:l) ? synIDattr(synIDtrans(synID(s:ln, s:c, 1)), 'name') : ''
|
||||
if s:g != s:last
|
||||
if s:start <= s:c - 1
|
||||
let s:txt = strpart(s:l, s:start - 1, s:c - s:start)
|
||||
call add(s:segs, s:last . ':' . s:txt)
|
||||
endif
|
||||
let s:last = s:g
|
||||
let s:start = s:c
|
||||
endif
|
||||
endfor
|
||||
echo printf('L%d %s', s:ln, join(s:segs, ' | '))
|
||||
endfor
|
||||
|
||||
echo ''
|
||||
echo '=== :syntax list (full) ==='
|
||||
syntax list
|
||||
|
||||
echo ''
|
||||
echo '=== :scriptnames (filtered) ==='
|
||||
let s:sn = execute('scriptnames')
|
||||
for s:line in split(s:sn, '\n')
|
||||
if s:line =~? 'vimwiki\|nuwiki\|syntax\|colors'
|
||||
echo s:line
|
||||
endif
|
||||
endfor
|
||||
|
||||
redir END
|
||||
echo 'Wrote /tmp/nuwiki-syndiag.log'
|
||||
Executable
+65
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# development/tests/test-keymaps-vim.sh — Vim counterpart of test-keymaps.sh.
|
||||
#
|
||||
# Pure-VimL portion only. The LSP-roundtrip keymaps (`<C-Space>`,
|
||||
# `=`, `-`, …) 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 development/tests/test-keymaps.lua, exercising the same
|
||||
# Lua command layer + server binary.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
TMP="$(mktemp -d -t nuwiki-keymap-vim-test-XXXXXX)"
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
log() { printf '\033[1;34m[keymap-vim]\033[0m %s\n' "$*"; }
|
||||
|
||||
if ! command -v vim >/dev/null 2>&1; then
|
||||
log 'vim not installed — skipping'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Don't need the LSP binary for the pure-VimL cases, but make sure
|
||||
# the autoload file `expand('<sfile>')` resolves correctly when the
|
||||
# script-load-time plugin_root capture runs.
|
||||
|
||||
mkdir -p "$TMP/wiki"
|
||||
SEED="$TMP/wiki/index.wiki"
|
||||
echo "= seed =" > "$SEED"
|
||||
|
||||
VIMRC="$TMP/vimrc"
|
||||
cat > "$VIMRC" <<EOF
|
||||
set nocompatible
|
||||
let &runtimepath = '$REPO_ROOT' . ',' . &runtimepath
|
||||
" Enable the opt-in mouse mappings before the ftplugin loads so the
|
||||
" harness can assert the full documented mapping surface (doc §6 mouse).
|
||||
let g:nuwiki_mouse_mappings = 1
|
||||
filetype plugin indent on
|
||||
syntax enable
|
||||
" ftdetect/nuwiki.vim already maps *.wiki → vimwiki via \`set filetype\`.
|
||||
EOF
|
||||
|
||||
RESULTS="$TMP/results.txt"
|
||||
|
||||
log "running harness…"
|
||||
# `</dev/null` so Vim's ex-mode doesn't hang waiting on stdin;
|
||||
# `timeout` so a misbehaving script can't stall CI for 30 minutes.
|
||||
NUWIKI_KEYMAP_RESULTS="$RESULTS" \
|
||||
timeout 30 vim -e -s -u "$VIMRC" -c "edit $SEED" -c "source $REPO_ROOT/development/tests/test-keymaps-vim.vim" \
|
||||
</dev/null >"$TMP/vim.log" 2>&1 || true
|
||||
|
||||
if [[ ! -f "$RESULTS" ]]; then
|
||||
echo 'keymap test harness produced no output' >&2
|
||||
echo '--- vim log ---' >&2
|
||||
cat "$TMP/vim.log" >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cat "$RESULTS"
|
||||
|
||||
if grep -qE '^SUMMARY: [0-9]+ passed, 0 failed' "$RESULTS"; then
|
||||
exit 0
|
||||
fi
|
||||
exit 1
|
||||
@@ -0,0 +1,460 @@
|
||||
" 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
|
||||
" reliably fire inside `vim -e -s` headless mode — so the cases here
|
||||
" are restricted to the bindings whose RHS is implemented entirely in
|
||||
" VimL (header/link nav, `o`/`O` bullet continuation, the wrap step
|
||||
" of `<CR>`). LSP-roundtrip bindings (`<C-Space>`, `=`, `-`, …) get
|
||||
" their coverage from the Neovim harness in development/tests/test-keymaps.lua
|
||||
" — same Lua codepath, same server.
|
||||
|
||||
let s:results = []
|
||||
let s:pass = 0
|
||||
let s:fail = 0
|
||||
let s:out = $NUWIKI_KEYMAP_RESULTS
|
||||
|
||||
function! s:record(ok, name, detail) abort
|
||||
let l:line = (a:ok ? 'PASS' : 'FAIL') . ' ' . a:name
|
||||
if a:detail !=# ''
|
||||
let l:line .= ' — ' . a:detail
|
||||
endif
|
||||
call add(s:results, l:line)
|
||||
if a:ok
|
||||
let s:pass += 1
|
||||
else
|
||||
let s:fail += 1
|
||||
endif
|
||||
endfunction
|
||||
|
||||
function! s:set_buf(lines) abort
|
||||
silent %delete _
|
||||
call setline(1, a:lines)
|
||||
endfunction
|
||||
|
||||
function! s:lines() abort
|
||||
return getline(1, '$')
|
||||
endfunction
|
||||
|
||||
function! s:run(name, opts) abort
|
||||
try
|
||||
call s:set_buf(a:opts.lines)
|
||||
let l:cursor = get(a:opts, 'cursor', [1, 1])
|
||||
call cursor(l:cursor[0], l:cursor[1])
|
||||
" feedkeys with 'tx' — t: typed (respects maps), x: execute now.
|
||||
let l:keys = a:opts.keys
|
||||
call feedkeys(l:keys, 'tx')
|
||||
let l:detail = ''
|
||||
if has_key(a:opts, 'expect_lines')
|
||||
if s:lines() !=# a:opts.expect_lines
|
||||
let l:detail = 'lines mismatch — expected ' . string(a:opts.expect_lines)
|
||||
\ . ', actual ' . string(s:lines())
|
||||
call s:record(0, a:name, l:detail)
|
||||
return
|
||||
endif
|
||||
endif
|
||||
if has_key(a:opts, 'expect_line') && has_key(a:opts, 'expect_at')
|
||||
let l:got = getline(a:opts.expect_at)
|
||||
if l:got !=# a:opts.expect_line
|
||||
let l:detail = 'line ' . a:opts.expect_at . ' — expected ' . string(a:opts.expect_line)
|
||||
\ . ', got ' . string(l:got)
|
||||
call s:record(0, a:name, l:detail)
|
||||
return
|
||||
endif
|
||||
endif
|
||||
if has_key(a:opts, 'expect_cursor_line')
|
||||
let l:lnum = line('.')
|
||||
if l:lnum != a:opts.expect_cursor_line
|
||||
let l:detail = 'cursor line — expected ' . a:opts.expect_cursor_line
|
||||
\ . ', got ' . l:lnum
|
||||
call s:record(0, a:name, l:detail)
|
||||
return
|
||||
endif
|
||||
endif
|
||||
call s:record(1, a:name, '')
|
||||
catch
|
||||
call s:record(0, a:name, v:exception)
|
||||
endtry
|
||||
endfunction
|
||||
|
||||
" ===== Smoke: ftplugin loaded + commands defined =====
|
||||
|
||||
if &filetype !=# 'vimwiki'
|
||||
call s:record(0, 'ftplugin.attached', 'filetype is ' . &filetype . ' (expected vimwiki)')
|
||||
else
|
||||
call s:record(1, 'ftplugin.attached', '')
|
||||
endif
|
||||
if exists(':VimwikiTOC') == 2
|
||||
call s:record(1, 'cmd.VimwikiTOC_defined', '')
|
||||
else
|
||||
call s:record(0, 'cmd.VimwikiTOC_defined', ':VimwikiTOC not registered')
|
||||
endif
|
||||
if exists(':NuwikiIndex') == 2
|
||||
call s:record(1, 'cmd.NuwikiIndex_defined', '')
|
||||
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 = [
|
||||
\ ['<CR>', 'n'], ['<S-CR>', 'n'], ['<C-CR>', 'n'], ['<C-S-CR>', 'n'],
|
||||
\ ['<BS>', 'n'], ['<Tab>', 'n'], ['<S-Tab>', 'n'], ['+', 'nx'],
|
||||
\ ['<C-Space>', 'nx'], ['<C-@>', 'nx'], ['<Nul>', 'nx'],
|
||||
\ ['gnt', 'n'], ['gln', 'nx'], ['glp', 'nx'], ['glx', 'nx'],
|
||||
\ ['glh', 'n'], ['gll', 'n'], ['gLh', 'n'], ['gLl', 'n'],
|
||||
\ ['glr', 'n'], ['gLr', 'n'], ['gl<Space>', 'n'], ['gL<Space>', 'n'],
|
||||
\ ['o', 'n'], ['O', 'n'],
|
||||
\ ['=', 'n'], ['-', 'n'], [']]', 'n'], ['[[', 'n'],
|
||||
\ [']=', 'n'], ['[=', 'n'], [']u', 'n'], ['[u', 'n'],
|
||||
\ ['gqq', 'n'], ['gq1', 'n'], ['gww', 'n'], ['gw1', 'n'],
|
||||
\ ['<A-Left>', 'n'], ['<A-Right>', 'n'],
|
||||
\ ['<Leader>ww', 'n'], ['<Leader>ws', 'n'], ['<Leader>wi', 'n'],
|
||||
\ ['<Leader>w<Leader>w', 'n'], ['<Leader>w<Leader>y', 'n'],
|
||||
\ ['<Leader>w<Leader>t', 'n'], ['<Leader>w<Leader>i', 'n'],
|
||||
\ ['<Leader>wn', 'n'], ['<Leader>wd', 'n'], ['<Leader>wr', 'n'],
|
||||
\ ['<Leader>wh', 'n'], ['<Leader>whh', 'n'], ['<Leader>wha', 'n'],
|
||||
\ ['<Leader>wc', 'nx'], ['<C-Down>', 'n'], ['<C-Up>', 'n'],
|
||||
\ ['<2-LeftMouse>', 'n'], ['<S-2-LeftMouse>', 'n'], ['<C-2-LeftMouse>', 'n'],
|
||||
\ ['<MiddleMouse>', 'n'], ['<RightMouse>', 'n'],
|
||||
\ ['ah', 'ox'], ['ih', 'ox'], ['aH', 'ox'], ['iH', 'ox'],
|
||||
\ ['al', 'ox'], ['il', 'ox'], ['a\', 'ox'], ['i\', 'ox'],
|
||||
\ ['ac', 'ox'], ['ic', 'ox'],
|
||||
\ ['<CR>', 'i'], ['<Tab>', 'i'], ['<S-Tab>', 'i'],
|
||||
\ ['<C-D>', 'i'], ['<C-T>', 'i'],
|
||||
\ ['<C-L><C-J>', 'i'], ['<C-L><C-K>', 'i'], ['<C-L><C-M>', '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', {
|
||||
\ 'lines': ['= One =', 'body', '== Two ==', 'body', '= Three ='],
|
||||
\ 'cursor': [1, 1],
|
||||
\ 'keys': ']]',
|
||||
\ 'expect_cursor_line': 3,
|
||||
\ })
|
||||
call s:run('headers.[[_jumps_to_prev', {
|
||||
\ 'lines': ['= One =', 'body', '== Two ==', 'body', '= Three ='],
|
||||
\ 'cursor': [5, 1],
|
||||
\ 'keys': '[[',
|
||||
\ 'expect_cursor_line': 3,
|
||||
\ })
|
||||
call s:run('headers.]u_jumps_to_parent', {
|
||||
\ 'lines': ['= Parent =', '== Child ==', 'body'],
|
||||
\ 'cursor': [3, 1],
|
||||
\ 'keys': ']u',
|
||||
\ 'expect_cursor_line': 1,
|
||||
\ })
|
||||
call s:run('headers.]=_jumps_to_next_sibling', {
|
||||
\ 'lines': ['= A =', '== child ==', 'body', '= B ='],
|
||||
\ 'cursor': [1, 1],
|
||||
\ '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) =====
|
||||
|
||||
call s:run('links.<Tab>_finds_next_wikilink', {
|
||||
\ 'lines': ['intro [[A]] more [[B]] end'],
|
||||
\ 'cursor': [1, 1],
|
||||
\ 'keys': "\<Tab>",
|
||||
\ 'expect_cursor_line': 1,
|
||||
\ })
|
||||
|
||||
" ===== <CR> first press wraps bare word =====
|
||||
|
||||
call s:run('cr.wraps_bare_word_first_press', {
|
||||
\ 'lines': ['MyPage rest'],
|
||||
\ 'cursor': [1, 3],
|
||||
\ 'keys': "\<CR>",
|
||||
\ 'expect_line': '[[MyPage]] rest',
|
||||
\ 'expect_at': 1,
|
||||
\ })
|
||||
|
||||
" ===== Bullet continuation =====
|
||||
|
||||
call s:run('lists.o_continues_list_marker', {
|
||||
\ 'lines': ['- first'],
|
||||
\ 'cursor': [1, 1],
|
||||
\ 'keys': "o\<Esc>",
|
||||
\ 'expect_lines': ['- first', '- '],
|
||||
\ })
|
||||
call s:run('lists.o_continues_checkbox', {
|
||||
\ 'lines': ['- [ ] first'],
|
||||
\ 'cursor': [1, 1],
|
||||
\ 'keys': "o\<Esc>",
|
||||
\ 'expect_lines': ['- [ ] first', '- [ ] '],
|
||||
\ })
|
||||
call s:run('lists.O_continues_above', {
|
||||
\ 'lines': ['- second'],
|
||||
\ 'cursor': [1, 1],
|
||||
\ 'keys': "O\<Esc>",
|
||||
\ 'expect_lines': ['- ', '- second'],
|
||||
\ })
|
||||
|
||||
" ===== Insert-mode smart_return (Cluster 5) =====
|
||||
|
||||
call s:run('cr.continues_list_marker', {
|
||||
\ 'lines': ['- first'],
|
||||
\ 'cursor': [1, 7],
|
||||
\ 'keys': "A\<CR>second\<Esc>",
|
||||
\ 'expect_lines': ['- first', '- second'],
|
||||
\ })
|
||||
call s:run('cr.continues_checkbox', {
|
||||
\ 'lines': ['- [ ] first'],
|
||||
\ 'cursor': [1, 11],
|
||||
\ 'keys': "A\<CR>second\<Esc>",
|
||||
\ 'expect_lines': ['- [ ] first', '- [ ] second'],
|
||||
\ })
|
||||
call s:run('cr.breaks_on_empty_list_line', {
|
||||
\ 'lines': ['- '],
|
||||
\ 'cursor': [1, 2],
|
||||
\ 'keys': "A\<CR>plain\<Esc>",
|
||||
\ 'expect_lines': ['', 'plain'],
|
||||
\ })
|
||||
call s:run('cr.adds_new_table_row', {
|
||||
\ 'lines': ['| a | b |'],
|
||||
\ 'cursor': [1, 4],
|
||||
\ 'keys': "A\<CR>x\<Esc>",
|
||||
\ 'expect_lines': ['| a | b |', '|x | |'],
|
||||
\ })
|
||||
|
||||
" ===== Insert-mode smart_tab / smart_shift_tab (Cluster 6) =====
|
||||
|
||||
call s:run('tab.next_cell_in_table', {
|
||||
\ 'lines': ['| a | b |'],
|
||||
\ 'cursor': [1, 3],
|
||||
\ 'keys': "A\<Tab>x\<Esc>",
|
||||
\ 'expect_lines': ['| a | b |', '|x | |'],
|
||||
\ })
|
||||
call s:run('tab.cycles_to_next_row_when_not_last', {
|
||||
\ 'lines': ['| a | b |', '|---|---|', '| c | d |'],
|
||||
\ 'cursor': [1, 7],
|
||||
\ 'keys': "i\<Tab>x\<Esc>",
|
||||
\ 'expect_lines': ['| a | b |', '|---|---|', '|x c | d |'],
|
||||
\ })
|
||||
call s:run('tab.inserts_row_only_when_on_last_row', {
|
||||
\ 'lines': ['| a | b |', '|---|---|', '| c | d |'],
|
||||
\ 'cursor': [3, 7],
|
||||
\ 'keys': "i\<Tab>x\<Esc>",
|
||||
\ 'expect_lines': ['| a | b |', '|---|---|', '| c | d |', '|x | |'],
|
||||
\ })
|
||||
call s:run('tab.from_first_cell_moves_to_second', {
|
||||
\ 'lines': ['| a | b |'],
|
||||
\ 'cursor': [1, 3],
|
||||
\ 'keys': "i\<Tab>x\<Esc>",
|
||||
\ 'expect_lines': ['| a |x b |'],
|
||||
\ })
|
||||
call s:run('shift_tab.prev_cell_in_table', {
|
||||
\ 'lines': ['| a | b |'],
|
||||
\ 'cursor': [1, 7],
|
||||
\ 'keys': "i\<S-Tab>x\<Esc>",
|
||||
\ 'expect_lines': ['|x a | b |'],
|
||||
\ })
|
||||
|
||||
" ===== Named link commands (Cluster 1) =====
|
||||
|
||||
if exists(':VimwikiNextLink') == 2
|
||||
call s:record(1, 'cmd.VimwikiNextLink_defined', '')
|
||||
else
|
||||
call s:record(0, 'cmd.VimwikiNextLink_defined', ':VimwikiNextLink not registered')
|
||||
endif
|
||||
if exists(':VimwikiPrevLink') == 2
|
||||
call s:record(1, 'cmd.VimwikiPrevLink_defined', '')
|
||||
else
|
||||
call s:record(0, 'cmd.VimwikiPrevLink_defined', ':VimwikiPrevLink not registered')
|
||||
endif
|
||||
if exists(':VimwikiBaddLink') == 2
|
||||
call s:record(1, 'cmd.VimwikiBaddLink_defined', '')
|
||||
else
|
||||
call s:record(0, 'cmd.VimwikiBaddLink_defined', ':VimwikiBaddLink not registered')
|
||||
endif
|
||||
|
||||
" ===== Text objects (Cluster 3) =====
|
||||
" Exercise the pure helpers directly — visual-mode mark inspection in
|
||||
" `vim -e -s` is unreliable for the same reasons the Lua harness uses
|
||||
" the helper-level tests.
|
||||
|
||||
call s:set_buf(['= h1 =', 'a', '== h2 ==', 'b', '== h3 ==', 'c'])
|
||||
call s:record(
|
||||
\ nuwiki#textobjects#_heading_block(1, 0) ==# [1, 2]
|
||||
\ ? 1 : 0,
|
||||
\ 'textobj.heading_block_section_only',
|
||||
\ 'got ' . string(nuwiki#textobjects#_heading_block(1, 0)))
|
||||
call s:record(
|
||||
\ nuwiki#textobjects#_heading_block(1, 1) ==# [1, 6]
|
||||
\ ? 1 : 0,
|
||||
\ 'textobj.heading_block_subtree',
|
||||
\ 'got ' . string(nuwiki#textobjects#_heading_block(1, 1)))
|
||||
|
||||
let s:cells = nuwiki#textobjects#_cell_ranges('| a | b | c |')
|
||||
call s:record(
|
||||
\ len(s:cells) == 3 ? 1 : 0,
|
||||
\ 'textobj.cell_ranges_three_cells',
|
||||
\ len(s:cells) == 3 ? '' : 'got ' . string(s:cells))
|
||||
|
||||
let s:cell_idx = nuwiki#textobjects#_current_cell_for_line('| a | b | c |', 8)
|
||||
call s:record(
|
||||
\ s:cell_idx == 2 ? 1 : 0,
|
||||
\ 'textobj.current_cell_picks_middle',
|
||||
\ s:cell_idx == 2 ? '' : 'got idx=' . s:cell_idx)
|
||||
|
||||
call setline(1, ['before', '| a | b |', '| c | d |', '', 'after'])
|
||||
let s:bnd = nuwiki#textobjects#_table_bounds(2)
|
||||
call s:record(
|
||||
\ s:bnd ==# [2, 3] ? 1 : 0,
|
||||
\ 'textobj.table_bounds_walks_contig_block',
|
||||
\ s:bnd ==# [2, 3] ? '' : 'got ' . string(s:bnd))
|
||||
|
||||
" Operator-pending: `dah` should delete the heading section.
|
||||
call s:set_buf(['= h1 =', 'body', '= h2 =', 'rest'])
|
||||
call cursor(1, 1)
|
||||
call feedkeys('dah', 'tx')
|
||||
let s:dah = getline(1, '$')
|
||||
call s:record(
|
||||
\ s:dah ==# ['= h2 =', 'rest'] || s:dah ==# ['', '= h2 =', 'rest']
|
||||
\ ? 1 : 0,
|
||||
\ 'textobj.dah_deletes_heading_section',
|
||||
\ 'got ' . string(s:dah))
|
||||
|
||||
" ===== Folding =====
|
||||
|
||||
call setline(1, ['= h1 =', 'a', '== h2 ==', 'b', 'c'])
|
||||
let &l:foldmethod = 'expr'
|
||||
let &l:foldexpr = 'nuwiki#folding#expr()'
|
||||
call s:record(
|
||||
\ foldlevel(1) > 0 ? 1 : 0,
|
||||
\ 'folding.heading_starts_fold',
|
||||
\ 'foldlevel(1)=' . foldlevel(1))
|
||||
call s:record(
|
||||
\ foldlevel(2) > 0 ? 1 : 0,
|
||||
\ 'folding.body_inherits_fold',
|
||||
\ 'foldlevel(2)=' . foldlevel(2))
|
||||
" ftplugin sets `foldlevel=99` so every heading-block fold starts open
|
||||
" rather than collapsed on file open.
|
||||
call s:record(
|
||||
\ &l:foldlevel == 99 ? 1 : 0,
|
||||
\ 'folding.starts_with_all_folds_open',
|
||||
\ '&l:foldlevel=' . &l:foldlevel)
|
||||
|
||||
" ===== Conceal =====
|
||||
|
||||
" The ftplugin reveals the cursor line in normal mode (concealcursor has no
|
||||
" `n`) so left/right motions move over visible characters instead of stalling
|
||||
" on hidden conceal regions like a wikilink's `[[`/`]]`.
|
||||
call s:record(
|
||||
\ &l:conceallevel == 2 ? 1 : 0,
|
||||
\ 'conceal.level_is_two',
|
||||
\ '&l:conceallevel=' . &l:conceallevel)
|
||||
call s:record(
|
||||
\ &l:concealcursor !~# 'n' ? 1 : 0,
|
||||
\ 'conceal.cursor_line_revealed_in_normal_mode',
|
||||
\ '&l:concealcursor=' . &l:concealcursor)
|
||||
|
||||
" ===== Wiki picker (config-driven, no LSP) =====
|
||||
|
||||
" The picker builds its list straight from config so it works from any buffer
|
||||
" before the LSP (which only starts on a wiki buffer) is running.
|
||||
let g:nuwiki_wikis = [
|
||||
\ {'name': 'Personal', 'root': '/tmp/nuwiki-a'},
|
||||
\ {'name': 'Work', 'root': '/tmp/nuwiki-b', 'file_extension': 'md'},
|
||||
\ ]
|
||||
let s:wl = nuwiki#commands#wiki_list()
|
||||
call s:record(
|
||||
\ len(s:wl) == 2 ? 1 : 0,
|
||||
\ 'wiki_select.lists_all_configured_wikis',
|
||||
\ 'count=' . len(s:wl))
|
||||
call s:record(
|
||||
\ get(get(s:wl, 0, {}), 'name', '') ==# 'Personal'
|
||||
\ && get(get(s:wl, 1, {}), 'name', '') ==# 'Work' ? 1 : 0,
|
||||
\ 'wiki_select.preserves_names_and_order',
|
||||
\ 'names=' . string(map(copy(s:wl), 'get(v:val, "name", "")')))
|
||||
call s:record(
|
||||
\ get(get(s:wl, 1, {}), 'ext', '') ==# '.md' ? 1 : 0,
|
||||
\ 'wiki_select.honours_per_wiki_extension',
|
||||
\ 'ext=' . get(get(s:wl, 1, {}), 'ext', ''))
|
||||
unlet g:nuwiki_wikis
|
||||
|
||||
" ===== Wrap up =====
|
||||
|
||||
call add(s:results, '')
|
||||
call add(s:results, printf('SUMMARY: %d passed, %d failed', s:pass, s:fail))
|
||||
call writefile(s:results, s:out)
|
||||
execute (s:fail == 0 ? 'qall!' : 'cquit!')
|
||||
@@ -0,0 +1,706 @@
|
||||
-- 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
|
||||
-- the buffer, places the cursor, fires the key sequence via
|
||||
-- `nvim_feedkeys`, waits for any LSP round-trip, and asserts the
|
||||
-- expected post-state.
|
||||
--
|
||||
-- Pass/fail summary is written to the path passed as argv[1] so the
|
||||
-- shell wrapper can read + render it without trying to capture nvim's
|
||||
-- TUI output.
|
||||
|
||||
local OUT = vim.env.NUWIKI_KEYMAP_RESULTS or '/tmp/nuwiki-keymap-results.txt'
|
||||
local out_lines = {}
|
||||
local pass = 0
|
||||
local fail = 0
|
||||
|
||||
local function record(ok, name, detail)
|
||||
local suffix = (detail and detail ~= '') and (' — ' .. detail) or ''
|
||||
table.insert(out_lines, (ok and 'PASS' or 'FAIL') .. ' ' .. name .. suffix)
|
||||
if ok then pass = pass + 1 else fail = fail + 1 end
|
||||
end
|
||||
|
||||
local function set_buf(lines)
|
||||
vim.api.nvim_buf_set_lines(0, 0, -1, false, lines)
|
||||
end
|
||||
|
||||
local function get_lines()
|
||||
return vim.api.nvim_buf_get_lines(0, 0, -1, false)
|
||||
end
|
||||
|
||||
local function get_line(n)
|
||||
return vim.api.nvim_buf_get_lines(0, n - 1, n, false)[1] or ''
|
||||
end
|
||||
|
||||
local function send(keys)
|
||||
vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes(keys, true, false, true), 'x', false)
|
||||
end
|
||||
|
||||
local function sleep(ms)
|
||||
vim.cmd('sleep ' .. ms .. 'm')
|
||||
end
|
||||
|
||||
--- Run one case: lines + cursor (1-based) + key sequence + expected
|
||||
--- post-state. `wait_ms` defaults to 800 (enough for an LSP
|
||||
--- executeCommand round-trip on a hot server).
|
||||
local function run(name, opts)
|
||||
local ok, err = pcall(function()
|
||||
set_buf(opts.lines)
|
||||
vim.api.nvim_win_set_cursor(0, opts.cursor or { 1, 0 })
|
||||
send(opts.keys)
|
||||
sleep(opts.wait_ms or 800)
|
||||
local actual_lines = get_lines()
|
||||
if opts.expect_lines then
|
||||
if not vim.deep_equal(actual_lines, opts.expect_lines) then
|
||||
error(string.format('lines mismatch\n expected: %s\n actual: %s',
|
||||
vim.inspect(opts.expect_lines), vim.inspect(actual_lines)))
|
||||
end
|
||||
end
|
||||
if opts.expect_line and opts.expect_at then
|
||||
local got = get_line(opts.expect_at)
|
||||
if got ~= opts.expect_line then
|
||||
error(string.format('line %d mismatch\n expected: %q\n actual: %q',
|
||||
opts.expect_at, opts.expect_line, got))
|
||||
end
|
||||
end
|
||||
if opts.expect_cursor_line then
|
||||
local lnum = vim.api.nvim_win_get_cursor(0)[1]
|
||||
if lnum ~= opts.expect_cursor_line then
|
||||
error(string.format('cursor line mismatch — expected %d, got %d',
|
||||
opts.expect_cursor_line, lnum))
|
||||
end
|
||||
end
|
||||
if opts.expect_buf_path then
|
||||
local got = vim.api.nvim_buf_get_name(0)
|
||||
if not got:find(opts.expect_buf_path, 1, true) then
|
||||
error(string.format('buffer path mismatch\n expected substring: %q\n actual: %q',
|
||||
opts.expect_buf_path, got))
|
||||
end
|
||||
end
|
||||
end)
|
||||
record(ok, name, ok and '' or tostring(err))
|
||||
end
|
||||
|
||||
local function get_lsp_clients(opts)
|
||||
local fn = vim.lsp.get_clients or vim.lsp.get_active_clients
|
||||
if not fn then return {} end
|
||||
return fn(opts)
|
||||
end
|
||||
|
||||
local function wait_for_lsp(timeout_ms)
|
||||
local deadline = vim.loop.now() + (timeout_ms or 5000)
|
||||
while vim.loop.now() < deadline do
|
||||
local clients = get_lsp_clients({ name = 'nuwiki', bufnr = 0 })
|
||||
if #clients > 0 and clients[1].server_capabilities
|
||||
and clients[1].server_capabilities.executeCommandProvider then
|
||||
return clients[1]
|
||||
end
|
||||
sleep(100)
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- ===== Capture server log_message events =====
|
||||
local server_log = {}
|
||||
local orig_handler = vim.lsp.handlers['window/logMessage']
|
||||
vim.lsp.handlers['window/logMessage'] = function(err, result, ctx, cfg)
|
||||
if result and result.message then
|
||||
table.insert(server_log, '[' .. tostring(result.type) .. '] ' .. result.message)
|
||||
end
|
||||
if orig_handler then return orig_handler(err, result, ctx, cfg) end
|
||||
end
|
||||
|
||||
-- ===== Smoke: LSP attached =====
|
||||
|
||||
vim.defer_fn(function()
|
||||
local client = wait_for_lsp()
|
||||
if not client then
|
||||
record(false, 'lsp.attached', 'no nuwiki client after 5s')
|
||||
vim.fn.writefile(out_lines, OUT)
|
||||
vim.cmd('qall!')
|
||||
return
|
||||
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
|
||||
{ '<CR>', 'n' }, { '<S-CR>', 'n' }, { '<C-CR>', 'n' }, { '<C-S-CR>', 'n' },
|
||||
{ '<BS>', 'n' }, { '<Tab>', 'n' }, { '<S-Tab>', 'n' }, { '+', 'nx' },
|
||||
-- Lists
|
||||
{ '<C-Space>', 'nx' }, { '<C-@>', 'nx' }, { '<Nul>', 'nx' },
|
||||
{ 'gnt', 'n' }, { 'gln', 'nx' }, { 'glp', 'nx' }, { 'glx', 'nx' },
|
||||
{ 'glh', 'n' }, { 'gll', 'n' }, { 'gLh', 'n' }, { 'gLl', 'n' },
|
||||
{ 'glr', 'n' }, { 'gLr', 'n' }, { 'gl<Space>', 'n' }, { 'gL<Space>', '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' },
|
||||
{ '<A-Left>', 'n' }, { '<A-Right>', 'n' },
|
||||
-- Wiki / diary / export
|
||||
{ '<Leader>ww', 'n' }, { '<Leader>ws', 'n' }, { '<Leader>wi', 'n' },
|
||||
{ '<Leader>w<Leader>w', 'n' }, { '<Leader>w<Leader>y', 'n' },
|
||||
{ '<Leader>w<Leader>t', 'n' }, { '<Leader>w<Leader>i', 'n' },
|
||||
{ '<Leader>wn', 'n' }, { '<Leader>wd', 'n' }, { '<Leader>wr', 'n' },
|
||||
{ '<Leader>wh', 'n' }, { '<Leader>whh', 'n' }, { '<Leader>wha', 'n' },
|
||||
{ '<Leader>wc', 'nx' }, { '<C-Down>', 'n' }, { '<C-Up>', 'n' },
|
||||
-- Mouse (opt-in)
|
||||
{ '<2-LeftMouse>', 'n' }, { '<S-2-LeftMouse>', 'n' }, { '<C-2-LeftMouse>', 'n' },
|
||||
{ '<MiddleMouse>', 'n' }, { '<RightMouse>', '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
|
||||
{ '<CR>', 'i' }, { '<Tab>', 'i' }, { '<S-Tab>', 'i' },
|
||||
{ '<C-D>', 'i' }, { '<C-T>', 'i' },
|
||||
{ '<C-L><C-J>', 'i' }, { '<C-L><C-K>', 'i' }, { '<C-L><C-M>', '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', {
|
||||
lines = { '= test =', '- [ ] task' },
|
||||
cursor = { 2, 0 },
|
||||
keys = '<C-Space>',
|
||||
expect_line = '- [X] task',
|
||||
expect_at = 2,
|
||||
})
|
||||
run('list.toggle_via_C-@', {
|
||||
lines = { '= test =', '- [ ] task' },
|
||||
cursor = { 2, 0 },
|
||||
keys = '<C-@>',
|
||||
expect_line = '- [X] task',
|
||||
expect_at = 2,
|
||||
})
|
||||
run('list.toggle_via_Nul', {
|
||||
lines = { '= test =', '- [ ] task' },
|
||||
cursor = { 2, 0 },
|
||||
keys = '<Nul>',
|
||||
expect_line = '- [X] task',
|
||||
expect_at = 2,
|
||||
})
|
||||
run('list.cycle_via_gln', {
|
||||
lines = { '- [ ] task' },
|
||||
cursor = { 1, 0 },
|
||||
keys = 'gln',
|
||||
expect_line = '- [.] task',
|
||||
expect_at = 1,
|
||||
})
|
||||
run('list.reject_via_glx', {
|
||||
lines = { '- [ ] task' },
|
||||
cursor = { 1, 0 },
|
||||
keys = 'glx',
|
||||
expect_line = '- [-] task',
|
||||
expect_at = 1,
|
||||
})
|
||||
run('list.toggle_round_trip', {
|
||||
lines = { '- [X] done' },
|
||||
cursor = { 1, 0 },
|
||||
keys = '<C-Space>',
|
||||
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<Space> current list / gL<Space> whole doc) =====
|
||||
|
||||
-- `gl<Space>` 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<Space>',
|
||||
expect_lines = {
|
||||
'- [ ] todo a', '',
|
||||
'paragraph', '',
|
||||
'- [ ] todo b', '- [X] done b',
|
||||
},
|
||||
})
|
||||
-- `gL<Space>` 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<Space>',
|
||||
expect_lines = {
|
||||
'- [ ] todo a', '',
|
||||
'paragraph', '',
|
||||
'- [ ] todo b',
|
||||
},
|
||||
})
|
||||
|
||||
-- ===== Next task (gnt) =====
|
||||
|
||||
run('list.gnt_jumps_to_unfinished', {
|
||||
lines = { '- [X] done', '- [ ] todo', '- [ ] later' },
|
||||
cursor = { 1, 0 },
|
||||
keys = 'gnt',
|
||||
expect_cursor_line = 2,
|
||||
wait_ms = 600,
|
||||
})
|
||||
|
||||
-- ===== Heading levels =====
|
||||
|
||||
run('heading.add_level_with_=', {
|
||||
lines = { '= H1 =' },
|
||||
cursor = { 1, 0 },
|
||||
keys = '=',
|
||||
expect_line = '== H1 ==',
|
||||
expect_at = 1,
|
||||
})
|
||||
run('heading.remove_level_with_-', {
|
||||
lines = { '== H2 ==' },
|
||||
cursor = { 1, 0 },
|
||||
keys = '-',
|
||||
expect_line = '= H2 =',
|
||||
expect_at = 1,
|
||||
})
|
||||
run('heading.add_level_clamps_at_6', {
|
||||
lines = { '====== H6 ======' },
|
||||
cursor = { 1, 0 },
|
||||
keys = '=',
|
||||
expect_line = '====== H6 ======', -- unchanged
|
||||
expect_at = 1,
|
||||
})
|
||||
|
||||
-- ===== Header nav (pure Lua, no LSP) =====
|
||||
|
||||
run('headers.]]_jumps_to_next', {
|
||||
lines = { '= One =', 'body', '== Two ==', 'body', '= Three =' },
|
||||
cursor = { 1, 0 },
|
||||
keys = ']]',
|
||||
expect_cursor_line = 3,
|
||||
wait_ms = 100,
|
||||
})
|
||||
run('headers.[[_jumps_to_prev', {
|
||||
lines = { '= One =', 'body', '== Two ==', 'body', '= Three =' },
|
||||
cursor = { 5, 0 },
|
||||
keys = '[[',
|
||||
expect_cursor_line = 3,
|
||||
wait_ms = 100,
|
||||
})
|
||||
run('headers.]u_jumps_to_parent', {
|
||||
lines = { '= Parent =', '== Child ==', 'body' },
|
||||
cursor = { 3, 0 },
|
||||
keys = ']u',
|
||||
expect_cursor_line = 1,
|
||||
wait_ms = 100,
|
||||
})
|
||||
|
||||
-- ===== Link nav (pure Lua) =====
|
||||
|
||||
run('links.<Tab>_jumps_to_next_wikilink', {
|
||||
lines = { 'intro [[A]] more [[B]] end' },
|
||||
cursor = { 1, 0 },
|
||||
keys = '<Tab>',
|
||||
expect_cursor_line = 1,
|
||||
wait_ms = 100,
|
||||
})
|
||||
|
||||
-- ===== <CR> two-step =====
|
||||
|
||||
run('cr.wraps_bare_word_first_press', {
|
||||
lines = { 'MyPage rest' },
|
||||
cursor = { 1, 3 },
|
||||
keys = '<CR>',
|
||||
expect_line = '[[MyPage]] rest',
|
||||
expect_at = 1,
|
||||
wait_ms = 200,
|
||||
})
|
||||
|
||||
-- ===== Link helpers (Cluster C) =====
|
||||
|
||||
run('links.normalize_via_+', {
|
||||
lines = { 'MyPage rest' },
|
||||
cursor = { 1, 3 },
|
||||
keys = '+',
|
||||
expect_line = '[[MyPage]] rest',
|
||||
expect_at = 1,
|
||||
wait_ms = 200,
|
||||
})
|
||||
|
||||
-- ===== Bullet continuation (o/O) =====
|
||||
|
||||
run('lists.o_continues_list_marker', {
|
||||
lines = { '- first' },
|
||||
cursor = { 1, 0 },
|
||||
keys = 'o<Esc>',
|
||||
expect_lines = { '- first', '- ' },
|
||||
wait_ms = 100,
|
||||
})
|
||||
run('lists.o_continues_checkbox', {
|
||||
lines = { '- [ ] first' },
|
||||
cursor = { 1, 0 },
|
||||
keys = 'o<Esc>',
|
||||
expect_lines = { '- [ ] first', '- [ ] ' },
|
||||
wait_ms = 100,
|
||||
})
|
||||
run('lists.O_continues_above', {
|
||||
lines = { '- second' },
|
||||
cursor = { 1, 0 },
|
||||
keys = 'O<Esc>',
|
||||
expect_lines = { '- ', '- second' },
|
||||
wait_ms = 100,
|
||||
})
|
||||
|
||||
-- ===== Insert-mode list bindings (Cluster 2) =====
|
||||
|
||||
run('insert.C-L_C-M_adds_checkbox', {
|
||||
lines = { '- task' },
|
||||
cursor = { 1, 0 },
|
||||
keys = 'A<C-L><C-M><Esc>',
|
||||
expect_line = '- [ ] task',
|
||||
expect_at = 1,
|
||||
wait_ms = 200,
|
||||
})
|
||||
run('insert.C-L_C-M_toggles_existing_checkbox', {
|
||||
lines = { '- [ ] task' },
|
||||
cursor = { 1, 0 },
|
||||
keys = 'A<C-L><C-M><Esc>',
|
||||
expect_line = '- [X] task',
|
||||
expect_at = 1,
|
||||
})
|
||||
-- End-to-end regression for changeSymbol over LSP — direct call
|
||||
-- (no keymap) so we can spot a stale `bin/nuwiki-ls` or a dispatch
|
||||
-- error fast. Toggle has separate coverage above via `<C-Space>`.
|
||||
do
|
||||
local ok, err = pcall(function()
|
||||
set_buf({ '- [ ] item', '- [ ] two' })
|
||||
sleep(200)
|
||||
vim.api.nvim_win_set_cursor(0, { 1, 0 })
|
||||
require('nuwiki.commands').list_change_symbol('*', false)
|
||||
sleep(1500)
|
||||
local got = get_line(1)
|
||||
if got ~= '* [ ] item' then
|
||||
error(string.format('change_symbol failed: got %q', got))
|
||||
end
|
||||
end)
|
||||
record(ok, 'direct.list_change_symbol_via_lsp', ok and '' or tostring(err))
|
||||
end
|
||||
run('insert.C-L_C-J_cycles_marker_forward', {
|
||||
lines = { '- item' },
|
||||
cursor = { 1, 0 },
|
||||
keys = 'A<C-L><C-J><Esc>',
|
||||
expect_line = '* item',
|
||||
expect_at = 1,
|
||||
wait_ms = 1500,
|
||||
})
|
||||
run('insert.C-L_C-K_cycles_marker_backward', {
|
||||
-- From `-` going backward wraps to the end of the order (`I)`).
|
||||
lines = { '- item' },
|
||||
cursor = { 1, 0 },
|
||||
keys = 'A<C-L><C-K><Esc>',
|
||||
expect_line = 'I) item',
|
||||
expect_at = 1,
|
||||
wait_ms = 1500,
|
||||
})
|
||||
|
||||
-- ===== Text objects (Cluster 3) =====
|
||||
-- Exercise the pure helpers directly (cell ranges, heading block,
|
||||
-- table bounds). The visual-mode integration is verified in a single
|
||||
-- end-to-end case via mark inspection, which exercises the full
|
||||
-- operator-pending path.
|
||||
|
||||
local function tobj_case(name, fn)
|
||||
local ok, err = pcall(fn)
|
||||
record(ok, name, ok and '' or tostring(err))
|
||||
end
|
||||
|
||||
tobj_case('textobj.heading_block_section_only', function()
|
||||
set_buf({ '= h1 =', 'a', '== h2 ==', 'b', '== h3 ==', 'c' })
|
||||
local s, e = require('nuwiki.textobjects')._heading_block(1, false)
|
||||
if not (s == 1 and e == 2) then
|
||||
error(string.format('section_only: got (%s,%s) want (1,2)', s, e))
|
||||
end
|
||||
end)
|
||||
tobj_case('textobj.heading_block_subtree', function()
|
||||
set_buf({ '= h1 =', 'a', '== h2 ==', 'b', '== h3 ==', 'c' })
|
||||
local s, e = require('nuwiki.textobjects')._heading_block(1, true)
|
||||
if not (s == 1 and e == 6) then
|
||||
error(string.format('subtree: got (%s,%s) want (1,6)', s, e))
|
||||
end
|
||||
end)
|
||||
tobj_case('textobj.cell_ranges_three_cells', function()
|
||||
local r = require('nuwiki.textobjects')._cell_ranges('| a | b | c |')
|
||||
if #r ~= 3 then error('want 3 cells, got ' .. #r) end
|
||||
-- Inside each cell, content includes the surrounding spaces.
|
||||
if not (r[1][1] == 2 and r[2][1] > r[1][2] and r[3][1] > r[2][2]) then
|
||||
error('cell ordering broken: ' .. vim.inspect(r))
|
||||
end
|
||||
end)
|
||||
tobj_case('textobj.current_cell_picks_middle', function()
|
||||
-- `| a | b | c |` cursor on the `b` (1-based col 8).
|
||||
local idx = require('nuwiki.textobjects')._current_cell_for_line(
|
||||
'| a | b | c |', 8)
|
||||
if idx ~= 2 then error('expected cell 2, got ' .. tostring(idx)) end
|
||||
end)
|
||||
tobj_case('textobj.table_bounds_walks_contig_block', function()
|
||||
set_buf({ 'before', '| a | b |', '| c | d |', '', 'after' })
|
||||
vim.api.nvim_win_set_cursor(0, { 2, 0 })
|
||||
local s, e = require('nuwiki.textobjects')._table_bounds(2)
|
||||
if not (s == 2 and e == 3) then
|
||||
error(string.format('want (2,3) got (%s,%s)', s, e))
|
||||
end
|
||||
end)
|
||||
|
||||
-- One end-to-end: operator-pending `dah` should delete the heading
|
||||
-- section. This avoids the visual-mode `'<`/`'>` mark dance and
|
||||
-- relies on Vim's actual operator pipeline to verify the keymap is
|
||||
-- correctly wired into `omap`.
|
||||
tobj_case('textobj.dah_deletes_heading_section', function()
|
||||
set_buf({ '= h1 =', 'body', '= h2 =', 'rest' })
|
||||
vim.api.nvim_win_set_cursor(0, { 1, 0 })
|
||||
send('dah')
|
||||
sleep(100)
|
||||
local got = get_lines()
|
||||
-- After deleting heading section (lines 1-2), buffer should be the
|
||||
-- remaining two lines. There may be a trailing empty line depending
|
||||
-- on linewise delete semantics; allow both shapes.
|
||||
if not (vim.deep_equal(got, { '= h2 =', 'rest' })
|
||||
or vim.deep_equal(got, { '', '= h2 =', 'rest' })) then
|
||||
error('dah result: ' .. vim.inspect(got))
|
||||
end
|
||||
end)
|
||||
|
||||
-- ===== Smart <CR> (Cluster 5) =====
|
||||
|
||||
run('cr.continues_list_marker', {
|
||||
lines = { '- first' },
|
||||
cursor = { 1, 7 }, -- end of line
|
||||
keys = 'A<CR>second<Esc>',
|
||||
expect_lines = { '- first', '- second' },
|
||||
wait_ms = 100,
|
||||
})
|
||||
run('cr.continues_checkbox', {
|
||||
lines = { '- [ ] first' },
|
||||
cursor = { 1, 11 },
|
||||
keys = 'A<CR>second<Esc>',
|
||||
expect_lines = { '- [ ] first', '- [ ] second' },
|
||||
wait_ms = 100,
|
||||
})
|
||||
run('cr.breaks_on_empty_list_line', {
|
||||
lines = { '- ' },
|
||||
cursor = { 1, 2 },
|
||||
keys = 'A<CR>plain<Esc>',
|
||||
expect_lines = { '', 'plain' },
|
||||
wait_ms = 100,
|
||||
})
|
||||
run('cr.adds_new_table_row', {
|
||||
lines = { '| a | b |' },
|
||||
cursor = { 1, 4 }, -- inside `a`
|
||||
keys = 'A<CR>x<Esc>',
|
||||
-- New row inserted below with empty cells; cursor jumps to first.
|
||||
expect_lines = { '| a | b |', '|x | |' },
|
||||
wait_ms = 100,
|
||||
})
|
||||
|
||||
-- ===== Insert-mode table navigation (Cluster 6) =====
|
||||
|
||||
run('tab.next_cell_in_table', {
|
||||
lines = { '| a | b |' },
|
||||
cursor = { 1, 2 }, -- inside cell `a`
|
||||
keys = 'A<Tab>x<Esc>',
|
||||
-- After A: cursor at end. <Tab>: no cell-start strictly after end
|
||||
-- so creates new row. Try cursor on inside-first-cell case below.
|
||||
-- Here cursor at end has no next cell so a new row is added.
|
||||
expect_lines = { '| a | b |', '|x | |' },
|
||||
wait_ms = 100,
|
||||
})
|
||||
run('tab.from_first_cell_moves_to_second', {
|
||||
lines = { '| a | b |' },
|
||||
cursor = { 1, 0 },
|
||||
keys = 'lli<Tab>x<Esc>',
|
||||
-- `ll` from col 0 → col 2 (on `a`). `i` enters insert. `<Tab>`
|
||||
-- jumps to col after next `|`. `x` types in second cell.
|
||||
expect_lines = { '| a |x b |' },
|
||||
wait_ms = 100,
|
||||
})
|
||||
run('tab.cycles_to_next_row_when_not_last', {
|
||||
lines = { '| a | b |', '|---|---|', '| c | d |' },
|
||||
cursor = { 1, 6 }, -- inside last cell of header row
|
||||
keys = 'i<Tab>x<Esc>',
|
||||
-- Past the final cell of row 1: skip separator, land in first cell of row 3.
|
||||
expect_lines = { '| a | b |', '|---|---|', '|x c | d |' },
|
||||
wait_ms = 100,
|
||||
})
|
||||
run('tab.inserts_row_only_when_on_last_row', {
|
||||
lines = { '| a | b |', '|---|---|', '| c | d |' },
|
||||
cursor = { 3, 6 }, -- inside last cell of last data row
|
||||
keys = 'i<Tab>x<Esc>',
|
||||
expect_lines = { '| a | b |', '|---|---|', '| c | d |', '|x | |' },
|
||||
wait_ms = 100,
|
||||
})
|
||||
run('shift_tab.prev_cell_in_table', {
|
||||
lines = { '| a | b |' },
|
||||
cursor = { 1, 6 }, -- inside cell `b`
|
||||
keys = 'i<S-Tab>x<Esc>',
|
||||
-- Cursor lands just after the previous cell's `|`; typing `x`
|
||||
-- inserts at col 2 (before the leading space inside cell 1).
|
||||
expect_lines = { '|x a | b |' },
|
||||
wait_ms = 100,
|
||||
})
|
||||
run('tab.outside_table_passes_through', {
|
||||
lines = { 'plain text' },
|
||||
cursor = { 1, 0 },
|
||||
keys = 'i<Tab>x<Esc>',
|
||||
-- Default Tab inserts a literal tab.
|
||||
expect_lines = { '\tx' .. 'plain text' },
|
||||
wait_ms = 100,
|
||||
})
|
||||
|
||||
-- ===== Diary nav =====
|
||||
-- Tested via the LSP open-* responses; can't fully validate without
|
||||
-- a populated diary, but at least confirm the maps fire without
|
||||
-- error.
|
||||
|
||||
-- ===== Wiki picker (config-driven, no LSP) =====
|
||||
-- The picker must build its list from config so it works from any buffer
|
||||
-- before the server attaches.
|
||||
tobj_case('wiki_select.config_list_reads_g_var', function()
|
||||
vim.g.nuwiki_wikis = {
|
||||
{ name = 'Personal', root = '/tmp/nuwiki-a' },
|
||||
{ name = 'Work', root = '/tmp/nuwiki-b', file_extension = 'md' },
|
||||
}
|
||||
local list = require('nuwiki.config').wiki_list()
|
||||
vim.g.nuwiki_wikis = nil
|
||||
if #list ~= 2 then error('want 2 wikis, got ' .. #list) end
|
||||
if list[1].name ~= 'Personal' or list[2].name ~= 'Work' then
|
||||
error('names/order broken: ' .. vim.inspect(list))
|
||||
end
|
||||
if list[2].ext ~= '.md' then
|
||||
error('per-wiki extension not honoured: ' .. tostring(list[2].ext))
|
||||
end
|
||||
end)
|
||||
|
||||
-- ===== Wrap up =====
|
||||
|
||||
table.insert(out_lines, '')
|
||||
-- Dump captured server log_messages only on failure — useful for
|
||||
-- diagnosing dispatch errors that the LSP swallows into log_message.
|
||||
if fail > 0 and #server_log > 0 then
|
||||
table.insert(out_lines, '--- server log_messages ---')
|
||||
for _, l in ipairs(server_log) do
|
||||
table.insert(out_lines, l)
|
||||
end
|
||||
table.insert(out_lines, '')
|
||||
end
|
||||
table.insert(out_lines, string.format('SUMMARY: %d passed, %d failed', pass, fail))
|
||||
vim.fn.writefile(out_lines, OUT)
|
||||
vim.cmd(fail == 0 and 'qall!' or 'cquit!')
|
||||
end, 1500)
|
||||
Executable
+71
@@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# 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
|
||||
# `workspace/executeCommand`), so we:
|
||||
# 1. ensure `bin/nuwiki-ls` is built (release),
|
||||
# 2. spin up a scratch wiki under a temp dir,
|
||||
# 3. invoke `nvim --headless` with a minimal init that loads the
|
||||
# plugin + calls `setup()`, then sources the harness Lua.
|
||||
#
|
||||
# Exit code mirrors the harness: 0 on green, 1 on any failure.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
TMP="$(mktemp -d -t nuwiki-keymap-test-XXXXXX)"
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
log() { printf '\033[1;34m[keymap-test]\033[0m %s\n' "$*"; }
|
||||
|
||||
# Always rebuild the LSP binary so harness runs against current sources.
|
||||
# A previous version only built when the symlink was missing, which let
|
||||
# stale binaries pass tests against unrelated code — incremental cargo
|
||||
# build is fast, so just always run it.
|
||||
BIN="$REPO_ROOT/bin/nuwiki-ls"
|
||||
log 'building nuwiki-ls (release, incremental)…'
|
||||
cargo build --release -p nuwiki-ls --manifest-path "$REPO_ROOT/Cargo.toml" >/dev/null
|
||||
mkdir -p "$REPO_ROOT/bin"
|
||||
ln -sf "$REPO_ROOT/target/release/nuwiki-ls" "$BIN"
|
||||
|
||||
mkdir -p "$TMP/wiki"
|
||||
echo "= seed =" > "$TMP/wiki/index.wiki"
|
||||
|
||||
INIT="$TMP/init.lua"
|
||||
cat > "$INIT" <<EOF
|
||||
-- Keep the harness self-contained; no plugin manager required.
|
||||
vim.opt.runtimepath:prepend('$REPO_ROOT')
|
||||
vim.g.nuwiki_binary_path = '$BIN'
|
||||
-- Enable the opt-in mouse mappings so the harness can assert the full
|
||||
-- documented mapping surface (doc §6 mouse group).
|
||||
require('nuwiki').setup({ wiki_root = '$TMP/wiki', mappings = { mouse = true } })
|
||||
EOF
|
||||
|
||||
RESULTS="$TMP/results.txt"
|
||||
log "running harness…"
|
||||
|
||||
# `nvim --headless -u init.lua FILE +luafile …` — the harness's
|
||||
# `vim.defer_fn` lets the LSP attach before tests fire, then writes
|
||||
# the results file and exits. Output path is plumbed through env.
|
||||
# Wrapped in `timeout` so a hung LSP / harness fails fast instead of
|
||||
# stalling CI runners indefinitely.
|
||||
NUWIKI_KEYMAP_RESULTS="$RESULTS" \
|
||||
timeout 60 nvim --clean -u "$INIT" --headless "$TMP/wiki/index.wiki" \
|
||||
-c "luafile $REPO_ROOT/development/tests/test-keymaps.lua" \
|
||||
>"$TMP/nvim.log" 2>&1 || true
|
||||
|
||||
if [[ ! -f "$RESULTS" ]]; then
|
||||
echo 'keymap test harness produced no output' >&2
|
||||
echo '--- nvim log ---' >&2
|
||||
cat "$TMP/nvim.log" >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cat "$RESULTS"
|
||||
|
||||
if grep -qE '^SUMMARY: [0-9]+ passed, 0 failed' "$RESULTS"; then
|
||||
exit 0
|
||||
fi
|
||||
exit 1
|
||||
Reference in New Issue
Block a user