Docs #1

Merged
gffranco merged 15 commits from docs into main 2026-05-30 18:35:41 +00:00
71 changed files with 2496 additions and 1914 deletions
+2 -2
View File
@@ -93,6 +93,6 @@ jobs:
sudo apt-get install -y --no-install-recommends vim sudo apt-get install -y --no-install-recommends vim
vim --version | head -1 vim --version | head -1
- name: run Neovim keymap harness - name: run Neovim keymap harness
run: ./scripts/test-keymaps.sh run: ./development/tests/test-keymaps.sh
- name: run Vim keymap harness - name: run Vim keymap harness
run: ./scripts/test-keymaps-vim.sh run: ./development/tests/test-keymaps-vim.sh
+2 -2
View File
@@ -122,8 +122,8 @@ no extra plugin.
### Try it without touching your config ### Try it without touching your config
```sh ```sh
./start-nvim.sh # spawns Neovim against an isolated sample wiki ./development/start-nvim.sh # spawns Neovim against an isolated sample wiki
./start-vim.sh # same, for plain Vim (clones vim-lsp on first run) ./development/start-vim.sh # same, for plain Vim (clones vim-lsp on first run)
``` ```
Both scripts build the LSP binary, seed a scratch wiki, and launch the Both scripts build the LSP binary, seed a scratch wiki, and launch the
-1425
View File
File diff suppressed because it is too large Load Diff
+22 -6
View File
@@ -1,5 +1,5 @@
" autoload/nuwiki/commands.vim — Vim-side wrappers around the LSP " autoload/nuwiki/commands.vim — Vim-side wrappers around the LSP
" commands the server advertises (Phase 19). " commands the server advertises.
" "
" Lives in autoload/ so the cost is paid only when a `:Vimwiki*` / " Lives in autoload/ so the cost is paid only when a `:Vimwiki*` /
" `:Nuwiki*` command is actually invoked. Dispatches via vim-lsp's " `:Nuwiki*` command is actually invoked. Dispatches via vim-lsp's
@@ -107,7 +107,7 @@ endfunction
function! s:notify_deferred(name) abort function! s:notify_deferred(name) abort
echohl WarningMsg echohl WarningMsg
echom 'nuwiki: ' . a:name . ' is not yet implemented — see SPEC §13.1' echom 'nuwiki: ' . a:name . ' is not yet implemented'
echohl None echohl None
endfunction endfunction
@@ -478,6 +478,13 @@ endfunction
function! nuwiki#commands#cycle_list_item() abort function! nuwiki#commands#cycle_list_item() abort
call s:exec_pos('nuwiki.list.cycleCheckbox') call s:exec_pos('nuwiki.list.cycleCheckbox')
endfunction endfunction
" `glp` cycles backward — same command, `reverse` flag in the payload.
function! nuwiki#commands#cycle_list_item_back() abort
let l:p = s:cursor_position()
let l:args = [{ 'uri': l:p['textDocument']['uri'],
\ 'position': l:p['position'], 'reverse': v:true }]
call s:exec('nuwiki.list.cycleCheckbox', l:args)
endfunction
function! nuwiki#commands#reject_list_item() abort function! nuwiki#commands#reject_list_item() abort
call s:exec_pos('nuwiki.list.rejectCheckbox') call s:exec_pos('nuwiki.list.rejectCheckbox')
endfunction endfunction
@@ -627,11 +634,20 @@ function! nuwiki#commands#rename_file() abort
endif endif
endfunction endfunction
" ===== §13.1 deferred ===== " ===== Deferred =====
" §13.1 Cluster A — list rewriters. " Cluster A — list rewriters.
" `gl<Space>` — remove done items from the current list only. Passing the
" cursor position scopes the server to the contiguous list block under it.
function! nuwiki#commands#list_remove_done() abort function! nuwiki#commands#list_remove_done() abort
let l:p = s:cursor_position()
let l:args = [{ 'uri': l:p['textDocument']['uri'], 'position': l:p['position'] }]
call s:exec('nuwiki.list.removeDone', l:args)
endfunction
" `gL<Space>` — remove done items across the whole buffer (no position).
function! nuwiki#commands#list_remove_done_all() abort
let l:args = [{ 'uri': s:buf_uri() }] let l:args = [{ 'uri': s:buf_uri() }]
call s:exec('nuwiki.list.removeDone', l:args) call s:exec('nuwiki.list.removeDone', l:args)
endfunction endfunction
@@ -721,7 +737,7 @@ function! nuwiki#commands#list_toggle_or_add_checkbox() abort
let l:new = strpart(l:line, 0, l:end) . '[ ] ' . strpart(l:line, l:end) let l:new = strpart(l:line, 0, l:end) . '[ ] ' . strpart(l:line, l:end)
call setline('.', l:new) call setline('.', l:new)
endfunction endfunction
" §13.1 Cluster B — table rewriters. " Cluster B — table rewriters.
function! nuwiki#commands#table_insert(...) abort function! nuwiki#commands#table_insert(...) abort
let l:cols = a:0 >= 1 ? str2nr(a:1) : 3 let l:cols = a:0 >= 1 ? str2nr(a:1) : 3
@@ -799,7 +815,7 @@ function! nuwiki#commands#colorize(...) abort
\ . strpart(l:line, l:right - 1) \ . strpart(l:line, l:right - 1)
call setline('.', l:new) call setline('.', l:new)
endfunction endfunction
" §13.1 Cluster C — link helpers. " Cluster C — link helpers.
function! nuwiki#commands#paste_link() abort function! nuwiki#commands#paste_link() abort
let l:p = s:cursor_position() let l:p = s:cursor_position()
+1 -1
View File
@@ -1,6 +1,6 @@
" autoload/nuwiki/lsp.vim — Vim (non-Neovim) LSP client wiring. " autoload/nuwiki/lsp.vim — Vim (non-Neovim) LSP client wiring.
" "
" Spec §7.4 preference order: " Preference order:
" 1. vim-lsp (matoto/vim-lsp) " 1. vim-lsp (matoto/vim-lsp)
" 2. coc.nvim " 2. coc.nvim
" 3. Print an error if neither is available. " 3. Print an error if neither is available.
+1 -3
View File
@@ -1,6 +1,4 @@
//! Block-level AST nodes and their supporting types. //! Block-level AST nodes and their supporting types.
//!
//! See SPEC.md §6.4.
use super::inline::InlineNode; use super::inline::InlineNode;
use super::span::Span; use super::span::Span;
@@ -17,7 +15,7 @@ pub enum BlockNode {
DefinitionList(DefinitionListNode), DefinitionList(DefinitionListNode),
Table(TableNode), Table(TableNode),
Comment(CommentNode), Comment(CommentNode),
/// Vimwiki tag line — `:tag1:tag2:`. Phase 12 (v1.1). The placement /// Vimwiki tag line — `:tag1:tag2:`. The placement
/// rule (file / heading / standalone) is captured in `TagScope` so /// rule (file / heading / standalone) is captured in `TagScope` so
/// LSP handlers and renderers can treat the three differently. /// LSP handlers and renderers can treat the three differently.
Tag(TagNode), Tag(TagNode),
+1 -1
View File
@@ -1,6 +1,6 @@
//! Inline AST nodes. //! Inline AST nodes.
//! //!
//! See SPEC.md §6.4. The link-related variants (`WikiLink`, `ExternalLink`, //! The link-related variants (`WikiLink`, `ExternalLink`,
//! `Transclusion`, `RawUrl`) wrap structs defined in `super::link`. //! `Transclusion`, `RawUrl`) wrap structs defined in `super::link`.
use super::link::{ExternalLinkNode, RawUrlNode, TransclusionNode, WikiLinkNode}; use super::link::{ExternalLinkNode, RawUrlNode, TransclusionNode, WikiLinkNode};
+1 -1
View File
@@ -1,6 +1,6 @@
//! Link-related AST nodes and supporting types. //! Link-related AST nodes and supporting types.
//! //!
//! See SPEC.md §6.4. All nodes here are inline (`InlineNode` variants); //! All nodes here are inline (`InlineNode` variants);
//! they live in their own module because the link model has enough surface //! they live in their own module because the link model has enough surface
//! area (kinds, anchors, interwiki indirection) to warrant separation. //! area (kinds, anchors, interwiki indirection) to warrant separation.
+4 -5
View File
@@ -1,7 +1,7 @@
//! AST types for nuwiki documents. //! AST types for nuwiki documents.
//! //!
//! Types here are syntax-agnostic — both vimwiki and (eventually) markdown //! Types here are syntax-agnostic — both vimwiki and (eventually) markdown
//! produce the same node shapes. See SPEC.md §6.4. //! produce the same node shapes.
pub mod block; pub mod block;
pub mod inline; pub mod inline;
@@ -28,7 +28,7 @@ pub use visit::{
walk_table, walk_table_row, Visitor, walk_table, walk_table_row, Visitor,
}; };
/// Root of an AST. See SPEC.md §6.4. /// Root of an AST.
#[derive(Debug, Clone, Default, PartialEq, Eq)] #[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct DocumentNode { pub struct DocumentNode {
pub span: Span, pub span: Span,
@@ -37,8 +37,7 @@ pub struct DocumentNode {
} }
/// Document-level placeholders parsed from `%title` / `%nohtml` / `%template` /// Document-level placeholders parsed from `%title` / `%nohtml` / `%template`
/// / `%date` directives, plus the v1.1 file-level tag accumulator /// / `%date` directives, plus the file-level tag accumulator.
/// (SPEC §12.3). See SPEC.md §9.
/// ///
/// New fields are added at the bottom; consumers should construct with /// New fields are added at the bottom; consumers should construct with
/// `..Default::default()` to stay forward-compatible. /// `..Default::default()` to stay forward-compatible.
@@ -48,7 +47,7 @@ pub struct PageMetadata {
pub nohtml: bool, pub nohtml: bool,
pub template: Option<String>, pub template: Option<String>,
pub date: Option<String>, pub date: Option<String>,
/// File-scope tags parsed by the v1.1 tag lexer (Phase 12). Convenience /// File-scope tags parsed by the tag lexer. Convenience
/// projection of the `BlockNode::Tag` nodes whose scope is `File`. /// projection of the `BlockNode::Tag` nodes whose scope is `File`.
pub tags: Vec<String>, pub tags: Vec<String>,
} }
+1 -1
View File
@@ -1,6 +1,6 @@
//! Source positions and spans carried on every AST node. //! Source positions and spans carried on every AST node.
//! //!
//! See SPEC.md §6.5. Positions are 0-indexed; `column` is a byte offset //! Positions are 0-indexed; `column` is a byte offset
//! within a line; `offset` is a byte offset from the start of the document. //! within a line; `offset` is a byte offset from the start of the document.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, PartialOrd, Ord)] #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, PartialOrd, Ord)]
+2 -2
View File
@@ -5,10 +5,10 @@
//! Override any method to short-circuit or augment traversal — call the //! Override any method to short-circuit or augment traversal — call the
//! matching `walk_*` from your override to keep descending. //! matching `walk_*` from your override to keep descending.
//! //!
//! Pattern follows `rustc`'s and `syn`'s visitors. See SPEC.md §6.4 + P4. //! Pattern follows `rustc`'s and `syn`'s visitors.
//! //!
//! Read-only for now. A `VisitorMut` flavor can be added when transformations //! Read-only for now. A `VisitorMut` flavor can be added when transformations
//! become relevant (post-Phase 5). //! become relevant.
use super::block::{ use super::block::{
BlockNode, BlockquoteNode, CommentNode, DefinitionItemNode, DefinitionListNode, ErrorNode, BlockNode, BlockquoteNode, CommentNode, DefinitionItemNode, DefinitionListNode, ErrorNode,
+3 -3
View File
@@ -1,6 +1,6 @@
//! Date primitives for the diary subsystem (Phase 16). //! Date primitives for the diary subsystem.
//! //!
//! v1.1 deliberately doesn't pull in `chrono` or `time`. The diary feature //! This crate deliberately doesn't pull in `chrono` or `time`. The diary feature
//! only needs `YYYY-MM-DD` parsing/formatting and ±1 day arithmetic, which //! only needs `YYYY-MM-DD` parsing/formatting and ±1 day arithmetic, which
//! is small enough that the dependency cost outweighs the convenience. //! is small enough that the dependency cost outweighs the convenience.
//! //!
@@ -56,7 +56,7 @@ impl DiaryDate {
format!("{:04}-{:02}-{:02}", self.year, self.month, self.day) format!("{:04}-{:02}-{:02}", self.year, self.month, self.day)
} }
/// UTC "today" computed from `SystemTime::now()`. Phase 16's diary /// UTC "today" computed from `SystemTime::now()`. The diary
/// commands intentionally use UTC — local-time semantics depend on a /// commands intentionally use UTC — local-time semantics depend on a
/// timezone DB we don't ship and would surprise users crossing DST /// timezone DB we don't ship and would surprise users crossing DST
/// boundaries. /// boundaries.
+1 -1
View File
@@ -1,7 +1,7 @@
//! Core parser, AST, and renderer for nuwiki. //! Core parser, AST, and renderer for nuwiki.
//! //!
//! This crate is editor-independent and must never depend on `nuwiki-lsp` or //! This crate is editor-independent and must never depend on `nuwiki-lsp` or
//! `nuwiki-ls`. See SPEC.md §6.2 for the dependency rules. //! `nuwiki-ls`.
pub mod ast; pub mod ast;
pub mod date; pub mod date;
+5 -5
View File
@@ -3,12 +3,12 @@
//! Walks a `DocumentNode` and writes HTML5 fragments. The renderer is //! Walks a `DocumentNode` and writes HTML5 fragments. The renderer is
//! configured with a link resolver (callback that turns a `LinkTarget` into //! configured with a link resolver (callback that turns a `LinkTarget` into
//! a URL string) and, optionally, an enclosing template with substitution //! a URL string) and, optionally, an enclosing template with substitution
//! placeholders (SPEC.md §6.8 + §12.8). //! placeholders.
//! //!
//! Substitution model: `{{content}}` and `{{title}}` are computed from //! Substitution model: `{{content}}` and `{{title}}` are computed from
//! the rendered body and the document's metadata; `with_var(k, v)` / //! the rendered body and the document's metadata; `with_var(k, v)` /
//! `with_vars(map)` add arbitrary key→value pairs (Phase 17 ships //! `with_vars(map)` add arbitrary key→value pairs (`{{date}}`,
//! `{{date}}`, `{{root_path}}`, `{{toc}}`). Order: `{{content}}` is //! `{{root_path}}`, `{{toc}}`). Order: `{{content}}` is
//! substituted first so a body that happens to contain a literal //! substituted first so a body that happens to contain a literal
//! `{{title}}` isn't itself rewritten in the next pass. //! `{{title}}` isn't itself rewritten in the next pass.
//! //!
@@ -44,7 +44,7 @@ pub struct HtmlRenderer {
/// `color_dic`-style override: vimwiki colour-tag names → CSS /// `color_dic`-style override: vimwiki colour-tag names → CSS
/// values (`"red"` / `"#ffe119"` / `"oklch(…)"`). Unspecified /// values (`"red"` / `"#ffe119"` / `"oklch(…)"`). Unspecified
/// names fall through to the default `class="color-<name>"` /// names fall through to the default `class="color-<name>"`
/// rendering. Matches SPEC §12.11 `color_dic`. /// rendering. Matches vimwiki's `color_dic`.
colors: HashMap<String, String>, colors: HashMap<String, String>,
} }
@@ -662,7 +662,7 @@ fn table_spans(table: &TableNode) -> Vec<Vec<CellLayout>> {
// ===== Default link resolver ===== // ===== Default link resolver =====
/// Default `LinkResolver`. Mirrors the conventions in SPEC.md §9: /// Default `LinkResolver`. Mirrors the vimwiki link conventions:
/// wiki pages become `path.html`; interwiki links land in sibling /// wiki pages become `path.html`; interwiki links land in sibling
/// directories; diary entries land under `diary/`; file/local schemes /// directories; diary entries land under `diary/`; file/local schemes
/// use their literal path; anchor-only links collapse to a fragment. /// use their literal path; anchor-only links collapse to a fragment.
+1 -1
View File
@@ -1,6 +1,6 @@
//! Document renderers. //! Document renderers.
//! //!
//! SPEC.md §6.8: a `Renderer` is writer-based, takes a `DocumentNode`, and //! A `Renderer` is writer-based, takes a `DocumentNode`, and
//! emits its representation into any `Write`. Errors propagate through //! emits its representation into any `Write`. Errors propagate through
//! `io::Result` — there's no separate per-renderer error type so the trait //! `io::Result` — there's no separate per-renderer error type so the trait
//! stays object-safe (`Box<dyn Renderer>` is useful for the LSP later). //! stays object-safe (`Box<dyn Renderer>` is useful for the LSP later).
+4 -6
View File
@@ -4,8 +4,6 @@
//! a `Lexer` produces a `TokenStream`, a `Parser` consumes it and produces a //! a `Lexer` produces a `TokenStream`, a `Parser` consumes it and produces a
//! `DocumentNode`. A `SyntaxPlugin` is the type-erased facade that the LSP //! `DocumentNode`. A `SyntaxPlugin` is the type-erased facade that the LSP
//! layer holds in a `SyntaxRegistry`. //! layer holds in a `SyntaxRegistry`.
//!
//! See SPEC.md §6.3 (interface) and §6.6 (lexer strategy).
pub mod registry; pub mod registry;
pub mod vimwiki; pub mod vimwiki;
@@ -15,7 +13,7 @@ pub use registry::SyntaxRegistry;
use crate::ast::DocumentNode; use crate::ast::DocumentNode;
/// Eager token buffer produced by a `Lexer`. The newtype keeps the door /// Eager token buffer produced by a `Lexer`. The newtype keeps the door
/// open to a lazy/streaming variant later (SPEC.md §6.6) without breaking /// open to a lazy/streaming variant later without breaking
/// callers. /// callers.
#[derive(Debug, Clone, Default, PartialEq, Eq)] #[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct TokenStream<T> { pub struct TokenStream<T> {
@@ -89,7 +87,7 @@ pub trait Lexer {
} }
/// Parser half of a syntax plugin. Consumes a `TokenStream` and produces a /// Parser half of a syntax plugin. Consumes a `TokenStream` and produces a
/// `DocumentNode`. Per SPEC.md §6.7 parsing is resilient — malformed input /// `DocumentNode`. Parsing is resilient — malformed input
/// becomes `BlockNode::Error(ErrorNode)`, never a hard failure. /// becomes `BlockNode::Error(ErrorNode)`, never a hard failure.
pub trait Parser { pub trait Parser {
type Token; type Token;
@@ -104,13 +102,13 @@ pub trait Parser {
/// hold heterogeneous plugins behind a single trait object. /// hold heterogeneous plugins behind a single trait object.
/// ///
/// `Send + Sync` is required — the LSP server stores plugins behind an `Arc` /// `Send + Sync` is required — the LSP server stores plugins behind an `Arc`
/// and shares them across request handler tasks (SPEC.md §6.3). /// and shares them across request handler tasks.
pub trait SyntaxPlugin: Send + Sync { pub trait SyntaxPlugin: Send + Sync {
fn id(&self) -> &str; fn id(&self) -> &str;
fn display_name(&self) -> &str; fn display_name(&self) -> &str;
/// File extensions associated with this syntax. Each entry includes the /// File extensions associated with this syntax. Each entry includes the
/// leading dot, e.g. `[".wiki"]`. See SPEC.md §6.3. /// leading dot, e.g. `[".wiki"]`.
fn file_extensions(&self) -> &[&str]; fn file_extensions(&self) -> &[&str];
fn parse(&self, text: &str) -> DocumentNode; fn parse(&self, text: &str) -> DocumentNode;
+1 -1
View File
@@ -1,4 +1,4 @@
//! Registry of syntax plugins. See SPEC.md §6.3. //! Registry of syntax plugins.
//! //!
//! Plugins are stored behind `Arc<dyn SyntaxPlugin>` so the LSP layer can //! Plugins are stored behind `Arc<dyn SyntaxPlugin>` so the LSP layer can
//! hand a cheap clone to per-document tasks without re-locking the registry. //! hand a cheap clone to per-document tasks without re-locking the registry.
@@ -1,6 +1,6 @@
//! Vimwiki lexer. //! Vimwiki lexer.
//! //!
//! Hand-rolled, two-pass per SPEC.md §6.6: //! Hand-rolled, two-pass:
//! //!
//! - **Block pass:** scan the source line by line, recognise structural //! - **Block pass:** scan the source line by line, recognise structural
//! constructs (headings, lists, tables, fences, comments, etc.), emit //! constructs (headings, lists, tables, fences, comments, etc.), emit
@@ -12,7 +12,7 @@
//! Both passes share one `Vec<VimwikiToken>` so the parser sees a single, //! Both passes share one `Vec<VimwikiToken>` so the parser sees a single,
//! ordered stream. The lexer is permissive: every delimiter is emitted; the //! ordered stream. The lexer is permissive: every delimiter is emitted; the
//! parser pairs them and falls back to literal text on mismatches. Spans are //! parser pairs them and falls back to literal text on mismatches. Spans are
//! stored as 0-indexed byte offsets per SPEC.md §6.5. //! stored as 0-indexed byte offsets.
//! //!
//! Multi-line constructs (`{{{ }}}`, `{{$ }}$`, `%%+ +%%`) flip a //! Multi-line constructs (`{{{ }}}`, `{{$ }}$`, `%%+ +%%`) flip a
//! `BlockMode` so subsequent lines are treated as raw content until the //! `BlockMode` so subsequent lines are treated as raw content until the
@@ -97,13 +97,13 @@ pub enum VimwikiTokenKind {
CommentMultiClose, CommentMultiClose,
CommentMultiLine(String), CommentMultiLine(String),
// ----- Tags (SPEC §12.3) ----- // ----- Tags -----
/// `:tag1:tag2:` — colon-delimited tag line. The lexer treats this as /// `:tag1:tag2:` — colon-delimited tag line. The lexer treats this as
/// block-level (must be the entire trimmed content of the line). /// block-level (must be the entire trimmed content of the line).
/// Scope (file / heading / standalone) is the parser's job. /// Scope (file / heading / standalone) is the parser's job.
Tag(Vec<String>), Tag(Vec<String>),
// ----- Page placeholders (SPEC §9) ----- // ----- Page placeholders -----
PlaceholderTitle(Option<String>), PlaceholderTitle(Option<String>),
PlaceholderNohtml, PlaceholderNohtml,
PlaceholderTemplate(Option<String>), PlaceholderTemplate(Option<String>),
@@ -129,7 +129,7 @@ pub enum VimwikiTokenKind {
TransclusionSep, TransclusionSep,
RawUrl(String), RawUrl(String),
/// Lex error — never aborts the whole document (SPEC §6.7). /// Lex error — never aborts the whole document.
Error(String), Error(String),
} }
@@ -481,7 +481,7 @@ impl<'src> LexState<'src> {
if trimmed.len() < 4 || !trimmed.bytes().all(|b| b == b'-') { if trimmed.len() < 4 || !trimmed.bytes().all(|b| b == b'-') {
return false; return false;
} }
// SPEC §9: four or more dashes. Allow surrounding whitespace. // Four or more dashes. Allow surrounding whitespace.
let span = Span::new(self.line_start_pos(), self.line_end_pos(line)); let span = Span::new(self.line_start_pos(), self.line_end_pos(line));
self.push(VimwikiTokenKind::HorizontalRule, span); self.push(VimwikiTokenKind::HorizontalRule, span);
self.emit_newline(line); self.emit_newline(line);
+1 -1
View File
@@ -10,7 +10,7 @@ use crate::ast::DocumentNode;
use crate::syntax::{Lexer, Parser, SyntaxPlugin}; use crate::syntax::{Lexer, Parser, SyntaxPlugin};
/// SyntaxPlugin facade: chains `VimwikiLexer` + `VimwikiParser` so the /// SyntaxPlugin facade: chains `VimwikiLexer` + `VimwikiParser` so the
/// registry can hand out a single trait object per SPEC.md §6.3. /// registry can hand out a single trait object.
#[derive(Debug, Default, Clone)] #[derive(Debug, Default, Clone)]
pub struct VimwikiSyntax; pub struct VimwikiSyntax;
@@ -1,6 +1,6 @@
//! Vimwiki parser. //! Vimwiki parser.
//! //!
//! Hand-rolled recursive-descent over `Vec<VimwikiToken>` (SPEC.md §6.7). //! Hand-rolled recursive-descent over `Vec<VimwikiToken>`.
//! Resilient: never aborts the document. Anything the dispatcher can't //! Resilient: never aborts the document. Anything the dispatcher can't
//! claim becomes a `BlockNode::Error(ErrorNode)` so progress is //! claim becomes a `BlockNode::Error(ErrorNode)` so progress is
//! guaranteed. //! guaranteed.
@@ -28,7 +28,7 @@
//! //!
//! `[[ ]]` payloads are inspected to choose between `WikiLinkNode` //! `[[ ]]` payloads are inspected to choose between `WikiLinkNode`
//! (the default) and `ExternalLinkNode` (when the target is a URL). The //! (the default) and `ExternalLinkNode` (when the target is a URL). The
//! `LinkTarget` enum follows SPEC §9: leading `/` is root-relative, //! `LinkTarget` enum follows the vimwiki conventions: leading `/` is root-relative,
//! `//` is filesystem-absolute, `wiki<N>:` / `wn.<Name>:` are interwiki, //! `//` is filesystem-absolute, `wiki<N>:` / `wn.<Name>:` are interwiki,
//! `diary:` / `file:` / `local:` are their own kinds, and a trailing `/` //! `diary:` / `file:` / `local:` are their own kinds, and a trailing `/`
//! marks a directory link. //! marks a directory link.
@@ -69,10 +69,10 @@ impl Parser for VimwikiParser {
struct ParseState<'a> { struct ParseState<'a> {
toks: &'a [VimwikiToken], toks: &'a [VimwikiToken],
pos: usize, pos: usize,
/// Phase 12: count of `HeadingNode`s already pushed to `children`. /// Count of `HeadingNode`s already pushed to `children`.
/// `headings_seen - 1` is the most recent heading's index. /// `headings_seen - 1` is the most recent heading's index.
headings_seen: usize, headings_seen: usize,
/// Phase 12: source line of the most recently-emitted heading. /// Source line of the most recently-emitted heading.
/// `tag_line - last_heading_line ∈ {1, 2}` is a header-scope tag. /// `tag_line - last_heading_line ∈ {1, 2}` is a header-scope tag.
last_heading_line: Option<u32>, last_heading_line: Option<u32>,
} }
@@ -141,7 +141,7 @@ impl<'a> ParseState<'a> {
} }
let before = self.pos; let before = self.pos;
let block = self.parse_block(); let block = self.parse_block();
// Phase 12: file-scope tags get accumulated into metadata so // File-scope tags get accumulated into metadata so
// callers can read page-level tag membership without walking // callers can read page-level tag membership without walking
// the AST. The TagNode itself stays in `children` so renderers // the AST. The TagNode itself stays in `children` so renderers
// see it too. // see it too.
@@ -280,7 +280,7 @@ impl<'a> ParseState<'a> {
let inline = parse_inline_seq(&self.toks[inline_start..self.pos]); let inline = parse_inline_seq(&self.toks[inline_start..self.pos]);
self.advance(); self.advance();
self.eat_newline(); self.eat_newline();
// Record for tag-scope resolution (Phase 12). // Record for tag-scope resolution.
self.headings_seen += 1; self.headings_seen += 1;
self.last_heading_line = Some(span_end.line); self.last_heading_line = Some(span_end.line);
return BlockNode::Heading(HeadingNode { return BlockNode::Heading(HeadingNode {
+1 -1
View File
@@ -136,7 +136,7 @@ fn registry_can_parse_through_trait_object() {
} }
/// Compile-time check: SyntaxRegistry must be Send + Sync so the LSP server /// Compile-time check: SyntaxRegistry must be Send + Sync so the LSP server
/// can share it across handler tasks (SPEC.md §6.3). /// can share it across handler tasks.
#[test] #[test]
fn registry_is_send_and_sync() { fn registry_is_send_and_sync() {
fn assert_send_sync<T: Send + Sync>() {} fn assert_send_sync<T: Send + Sync>() {}
+1 -1
View File
@@ -1,4 +1,4 @@
//! Phase 12 tag tests: lexer + parser + renderer behaviour on //! Tag tests: lexer + parser + renderer behaviour on
//! `:tag:` lines, plus the scope-detection rules. //! `:tag:` lines, plus the scope-detection rules.
use nuwiki_core::ast::{BlockNode, TagScope}; use nuwiki_core::ast::{BlockNode, TagScope};
+93 -29
View File
@@ -1,10 +1,10 @@
//! `workspace/executeCommand` dispatcher. //! `workspace/executeCommand` dispatcher.
//! //!
//! Phase 13 introduced the router + `nuwiki.file.delete`. Phase 14 added //! The router handles `nuwiki.file.delete`, the cheap surgical edits —
//! the cheap surgical edits — checkbox toggle/cycle/reject, heading //! checkbox toggle/cycle/reject, heading promote/demote, "next task"
//! promote/demote, "next task" navigation. Phase 15 layers on the //! navigation — and the generation + workspace-query commands:
//! generation + workspace-query commands: `toc.generate`, //! `toc.generate`, `links.generate`, `workspace.checkLinks`,
//! `links.generate`, `workspace.checkLinks`, `workspace.findOrphans`. //! `workspace.findOrphans`.
//! //!
//! Every command resolves to either a `WorkspaceEdit` (the server then //! Every command resolves to either a `WorkspaceEdit` (the server then
//! calls `apply_edit`) or a JSON `Value` (returned to the client) — see //! calls `apply_edit`) or a JSON `Value` (returned to the client) — see
@@ -87,7 +87,17 @@ pub(crate) async fn execute(
Ok(list_checkbox(backend, args, ops::toggle_state)?.map(CommandOutcome::Edit)) Ok(list_checkbox(backend, args, ops::toggle_state)?.map(CommandOutcome::Edit))
} }
"nuwiki.list.cycleCheckbox" => { "nuwiki.list.cycleCheckbox" => {
Ok(list_checkbox(backend, args, ops::cycle_state)?.map(CommandOutcome::Edit)) let reverse = args
.first()
.and_then(|v| v.get("reverse"))
.and_then(Value::as_bool)
.unwrap_or(false);
let mutate = if reverse {
ops::cycle_state_back
} else {
ops::cycle_state
};
Ok(list_checkbox(backend, args, mutate)?.map(CommandOutcome::Edit))
} }
"nuwiki.list.rejectCheckbox" => { "nuwiki.list.rejectCheckbox" => {
Ok(list_checkbox(backend, args, ops::reject_state)?.map(CommandOutcome::Edit)) Ok(list_checkbox(backend, args, ops::reject_state)?.map(CommandOutcome::Edit))
@@ -653,7 +663,7 @@ fn tags_rebuild(backend: &Backend, args: Vec<Value>) -> Result<Option<Value>, St
}))) })))
} }
// ===== Phase 18: multi-wiki picker commands ===== // ===== multi-wiki picker commands =====
fn wiki_list_all(backend: &Backend) -> Result<Option<Value>, String> { fn wiki_list_all(backend: &Backend) -> Result<Option<Value>, String> {
let wikis = backend.wikis_snapshot(); let wikis = backend.wikis_snapshot();
@@ -811,7 +821,7 @@ fn wiki_goto_page(backend: &Backend, args: Vec<Value>) -> Result<Option<Value>,
}))) })))
} }
// ===== §13.1 Cluster A: list rewriters ===== // ===== Cluster A: list rewriters =====
fn list_remove_done(backend: &Backend, args: Vec<Value>) -> Result<Option<WorkspaceEdit>, String> { fn list_remove_done(backend: &Backend, args: Vec<Value>) -> Result<Option<WorkspaceEdit>, String> {
let p = parse_optional_uri_arg(args.clone())?; let p = parse_optional_uri_arg(args.clone())?;
@@ -944,7 +954,7 @@ fn list_change_level(backend: &Backend, args: Vec<Value>) -> Result<Option<Works
)) ))
} }
// ===== §13.1 Cluster B: table rewriters ===== // ===== Cluster B: table rewriters =====
fn table_insert(_backend: &Backend, args: Vec<Value>) -> Result<Option<WorkspaceEdit>, String> { fn table_insert(_backend: &Backend, args: Vec<Value>) -> Result<Option<WorkspaceEdit>, String> {
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -1020,7 +1030,7 @@ fn table_move_column(backend: &Backend, args: Vec<Value>) -> Result<Option<Works
)) ))
} }
// ===== Phase 17: HTML export commands ===== // ===== HTML export commands =====
fn export_current( fn export_current(
backend: &Backend, backend: &Backend,
@@ -1233,6 +1243,20 @@ pub mod ops {
} }
} }
/// Exact inverse of [`cycle_state`] — used by the `glp` mapping
/// ("cycle the checkbox state backward").
pub fn cycle_state_back(current: &str) -> Option<&'static str> {
match current {
"[ ]" => Some("[X]"),
"[X]" => Some("[O]"),
"[O]" => Some("[o]"),
"[o]" => Some("[.]"),
"[.]" => Some("[ ]"),
"[-]" => Some("[ ]"),
_ => None,
}
}
/// Toggle the `[-]` "rejected" state. /// Toggle the `[-]` "rejected" state.
pub fn reject_state(current: &str) -> Option<&'static str> { pub fn reject_state(current: &str) -> Option<&'static str> {
match current { match current {
@@ -1647,7 +1671,7 @@ pub mod ops {
None None
} }
// ===== Phase 15: TOC + Links generation, workspace queries ===== // ===== TOC + Links generation, workspace queries =====
use serde::Serialize; use serde::Serialize;
@@ -1973,7 +1997,7 @@ pub mod ops {
/// on the diagnostics module's internals. /// on the diagnostics module's internals.
pub use crate::diagnostics::collect_wiki_links; pub use crate::diagnostics::collect_wiki_links;
// ===== §13.1 Cluster A: list rewriters ===== // ===== Cluster A: list rewriters =====
use crate::edits::text_edit_delete; use crate::edits::text_edit_delete;
use nuwiki_core::ast::ListSymbol; use nuwiki_core::ast::ListSymbol;
@@ -2095,7 +2119,9 @@ pub mod ops {
/// Delete every checkbox item whose state is `Done` or `Rejected`, /// Delete every checkbox item whose state is `Done` or `Rejected`,
/// cascading into their sublists. When `pos` is `Some`, restrict to /// cascading into their sublists. When `pos` is `Some`, restrict to
/// the list containing `pos`'s line; otherwise sweep the whole doc. /// the contiguous list block containing `pos`'s line (the "current
/// list" — including its nested sublists); otherwise sweep the whole
/// doc.
pub fn remove_done_edit( pub fn remove_done_edit(
text: &str, text: &str,
ast: &DocumentNode, ast: &DocumentNode,
@@ -2103,16 +2129,8 @@ pub mod ops {
pos: Option<LspPosition>, pos: Option<LspPosition>,
utf8: bool, utf8: bool,
) -> Option<WorkspaceEdit> { ) -> Option<WorkspaceEdit> {
let restrict_line = pos.map(|p| p.line);
let mut victims: Vec<Span> = Vec::new(); let mut victims: Vec<Span> = Vec::new();
walk_list_items(&ast.children, &mut |item| { let mut collect = |item: &ListItemNode| {
let matches_scope = match restrict_line {
None => true,
Some(line) => (item.span.start.line..=item.span.end.line).contains(&line),
};
if !matches_scope {
return;
}
if matches!( if matches!(
item.checkbox, item.checkbox,
Some(nuwiki_core::ast::CheckboxState::Done) Some(nuwiki_core::ast::CheckboxState::Done)
@@ -2120,7 +2138,19 @@ pub mod ops {
) { ) {
victims.push(extend_to_line_end(text, item.span)); victims.push(extend_to_line_end(text, item.span));
} }
}); };
match pos {
None => walk_list_items(&ast.children, &mut collect),
Some(p) => {
// "Current list" = the top-level contiguous list block the
// cursor sits in. Walk just that block (and its sublists),
// leaving done items in sibling lists untouched.
let list = find_top_list_at_line(ast, p.line)?;
for item in &list.items {
walk_list_items_in_item(item, &mut collect);
}
}
}
if victims.is_empty() { if victims.is_empty() {
return None; return None;
} }
@@ -2131,6 +2161,40 @@ pub mod ops {
Some(b.build()) Some(b.build())
} }
/// Find the top-level (contiguous) list block whose span contains
/// `line`. Unlike [`find_list_at_line`], this does not descend into
/// sublists — it returns the outermost list so callers operate on the
/// whole list block the cursor belongs to.
fn find_top_list_at_line(ast: &DocumentNode, line: u32) -> Option<&ListNode> {
for block in &ast.children {
if let Some(list) = find_top_list_in_block(block, line) {
return Some(list);
}
}
None
}
fn find_top_list_in_block(block: &BlockNode, line: u32) -> Option<&ListNode> {
match block {
BlockNode::List(list) => {
if (list.span.start.line..=list.span.end.line).contains(&line) {
Some(list)
} else {
None
}
}
BlockNode::Blockquote(BlockquoteNode { children, .. }) => {
for c in children {
if let Some(l) = find_top_list_in_block(c, line) {
return Some(l);
}
}
None
}
_ => None,
}
}
/// Extend a span to include the trailing newline (so deletion removes /// Extend a span to include the trailing newline (so deletion removes
/// the empty line that would otherwise be left behind). /// the empty line that would otherwise be left behind).
fn extend_to_line_end(text: &str, span: Span) -> Span { fn extend_to_line_end(text: &str, span: Span) -> Span {
@@ -2480,7 +2544,7 @@ pub mod ops {
Some((span, marker_str, i)) Some((span, marker_str, i))
} }
// ===== §13.1 Cluster B: table rewriters ===== // ===== Cluster B: table rewriters =====
use nuwiki_core::ast::{TableNode, TableRowNode}; use nuwiki_core::ast::{TableNode, TableRowNode};
@@ -2738,7 +2802,7 @@ pub mod ops {
Some(out) Some(out)
} }
// ===== Phase 16: diary helpers ===== // ===== diary helpers =====
use crate::edits::op_create; use crate::edits::op_create;
@@ -2792,10 +2856,10 @@ pub mod ops {
b.build() b.build()
} }
// ===== Tag ops (Phase 12 commands) ===== // ===== Tag ops =====
// //
// SPEC §12.3 calls for three tag-driven commands beyond the indexing // Three tag-driven commands beyond the indexing itself.
// that Phase 12 already shipped. `tag_hits` powers `nuwiki.tags.search`; // `tag_hits` powers `nuwiki.tags.search`;
// `tag_links_edit` powers `nuwiki.tags.generateLinks`; the rebuild // `tag_links_edit` powers `nuwiki.tags.generateLinks`; the rebuild
// command is a direct re-walk of the wiki root in the dispatcher. // command is a direct re-walk of the wiki root in the dispatcher.
@@ -2984,7 +3048,7 @@ pub mod ops {
} }
} }
/// Disk-touching helpers for the Phase 17 `nuwiki.export.*` commands. /// Disk-touching helpers for the `nuwiki.export.*` commands.
/// Kept out of `ops` because everything here is side-effecting — pure /// Kept out of `ops` because everything here is side-effecting — pure
/// rendering / templating lives in [`crate::export`]. /// rendering / templating lives in [`crate::export`].
pub mod export_ops { pub mod export_ops {
+17 -19
View File
@@ -1,14 +1,13 @@
//! Server-side runtime config. //! Server-side runtime config.
//! //!
//! Populated from `InitializeParams.initialization_options` at boot and //! Populated from `InitializeParams.initialization_options` at boot and
//! refreshed via `workspace/didChangeConfiguration` (P18). v1.0 single-wiki //! refreshed via `workspace/didChangeConfiguration`. Legacy single-wiki
//! shapes are accepted and desugared into the v1.1 `wikis = [...]` form so //! shapes are accepted and desugared into the `wikis = [...]` form so
//! existing setups keep working (P16). //! existing setups keep working.
//! //!
//! Only the fields actually consumed in Phase 11 land here. Phases 1319 //! `WikiConfig` (diary path, html path, template options, …) and the
//! extend `WikiConfig` (diary path, html path, template options, …) and //! top-level `Config` (diagnostic severity, mappings opt-in, …) carry the
//! the top-level `Config` (diagnostic severity, mappings opt-in, …) as //! fields actually consumed by the server.
//! they need them.
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@@ -39,9 +38,8 @@ pub struct WikiConfig {
/// The full path is `<root>/<diary_rel_path>/<diary_index>.wiki`. /// The full path is `<root>/<diary_rel_path>/<diary_index>.wiki`.
pub diary_index: String, pub diary_index: String,
/// `daily`/`weekly`/`monthly`/`yearly`. Picks the date format the /// `daily`/`weekly`/`monthly`/`yearly`. Picks the date format the
/// diary commands use. v1.1 ships `daily`; the others fall through /// diary commands use. `daily` is supported; the others fall through
/// to vimwiki-style names but the commands are no-ops until /// to vimwiki-style names but the commands are no-ops for them.
/// Cluster 4 lands.
pub diary_frequency: String, pub diary_frequency: String,
/// First day of the week (`monday`..`sunday`). Used by weekly /// First day of the week (`monday`..`sunday`). Used by weekly
/// diary entry naming. /// diary entry naming.
@@ -74,7 +72,7 @@ pub struct WikiConfig {
pub nested_syntaxes: std::collections::HashMap<String, String>, pub nested_syntaxes: std::collections::HashMap<String, String>,
/// Rebuild the page's TOC on save when set. /// Rebuild the page's TOC on save when set.
pub auto_toc: bool, pub auto_toc: bool,
/// Phase 17 HTML export options. See SPEC §12.8. /// HTML export options.
pub html: HtmlConfig, pub html: HtmlConfig,
} }
@@ -104,7 +102,7 @@ pub struct HtmlConfig {
/// to skip during `allToHtml*`. /// to skip during `allToHtml*`.
pub exclude_files: Vec<String>, pub exclude_files: Vec<String>,
/// `color_dic`: vimwiki colour-tag name → CSS value mapping used /// `color_dic`: vimwiki colour-tag name → CSS value mapping used
/// by the HTML renderer (SPEC §12.11). Empty by default; the /// by the HTML renderer. Empty by default; the
/// renderer falls back to `class="color-<name>"` when a name /// renderer falls back to `class="color-<name>"` when a name
/// isn't in the dict. /// isn't in the dict.
pub color_dic: std::collections::HashMap<String, String>, pub color_dic: std::collections::HashMap<String, String>,
@@ -130,7 +128,7 @@ impl Default for HtmlConfig {
impl HtmlConfig { impl HtmlConfig {
/// Synthesise default paths under `root` when the user hasn't /// Synthesise default paths under `root` when the user hasn't
/// specified explicit ones. `html_path` defaults to `<root>/_html` /// specified explicit ones. `html_path` defaults to `<root>/_html`
/// and `template_path` to `<root>/_templates` per SPEC §12.8. /// and `template_path` to `<root>/_templates`.
pub fn from_root(root: &Path) -> Self { pub fn from_root(root: &Path) -> Self {
Self { Self {
html_path: root.join("_html"), html_path: root.join("_html"),
@@ -284,7 +282,7 @@ fn default_links_space_char() -> String {
" ".to_string() " ".to_string()
} }
/// Fill in the v1.1 per-wiki keys that none of the constructors care /// Fill in the per-wiki keys that none of the constructors care
/// about. Centralising the defaults here keeps `empty()`, `from_root()`, /// about. Centralising the defaults here keeps `empty()`, `from_root()`,
/// the legacy single-wiki path, and `From<RawWiki>` in sync. /// the legacy single-wiki path, and `From<RawWiki>` in sync.
fn wiki_defaults() -> WikiDefaults { fn wiki_defaults() -> WikiDefaults {
@@ -329,9 +327,9 @@ impl Config {
/// Build a `Config` from `InitializeParams`. /// Build a `Config` from `InitializeParams`.
/// ///
/// Priority for the wikis list: /// Priority for the wikis list:
/// 1. `initialization_options.wikis = [...]` (v1.1 shape) /// 1. `initialization_options.wikis = [...]` (multi-wiki shape)
/// 2. `initialization_options.wiki_root + file_extension + syntax` /// 2. `initialization_options.wiki_root + file_extension + syntax`
/// (v1.0 single-wiki shape, P16) /// (legacy single-wiki shape)
/// 3. `workspace_folders[0]` if present /// 3. `workspace_folders[0]` if present
/// 4. deprecated `root_uri` if present /// 4. deprecated `root_uri` if present
/// 5. empty list (server stays alive but won't index anything) /// 5. empty list (server stays alive but won't index anything)
@@ -445,7 +443,7 @@ mod opt_bool_or_int {
#[serde(default, rename_all = "snake_case")] #[serde(default, rename_all = "snake_case")]
struct InitOptions { struct InitOptions {
wikis: Option<Vec<RawWiki>>, wikis: Option<Vec<RawWiki>>,
// v1.0 single-wiki compat fields // legacy single-wiki compat fields
wiki_root: Option<String>, wiki_root: Option<String>,
file_extension: Option<String>, file_extension: Option<String>,
syntax: Option<String>, syntax: Option<String>,
@@ -466,7 +464,7 @@ struct RawWiki {
diary_rel_path: Option<String>, diary_rel_path: Option<String>,
#[serde(default)] #[serde(default)]
diary_index: Option<String>, diary_index: Option<String>,
// Phase 17 HTML keys — all optional, fall back to per-root defaults. // HTML keys — all optional, fall back to per-root defaults.
#[serde(default)] #[serde(default)]
html_path: Option<String>, html_path: Option<String>,
#[serde(default)] #[serde(default)]
@@ -487,7 +485,7 @@ struct RawWiki {
exclude_files: Option<Vec<String>>, exclude_files: Option<Vec<String>>,
#[serde(default)] #[serde(default)]
color_dic: Option<std::collections::HashMap<String, String>>, color_dic: Option<std::collections::HashMap<String, String>>,
// v1.1 per-wiki keys mirroring vimwiki globals. All optional so // per-wiki keys mirroring vimwiki globals. All optional so
// existing single-wiki configs migrate without touching anything. // existing single-wiki configs migrate without touching anything.
#[serde(default)] #[serde(default)]
index: Option<String>, index: Option<String>,
+3 -4
View File
@@ -1,6 +1,6 @@
//! Diagnostic sources beyond parse-time `ErrorNode`s. //! Diagnostic sources beyond parse-time `ErrorNode`s.
//! //!
//! Phase 15 lands the `nuwiki.link` source broken-link warnings. Walks //! The `nuwiki.link` source emits broken-link warnings. Walks
//! every `WikiLinkNode` in the AST and emits one diagnostic per: //! every `WikiLinkNode` in the AST and emits one diagnostic per:
//! - `Wiki` target that doesn't resolve to a page in the workspace index, //! - `Wiki` target that doesn't resolve to a page in the workspace index,
//! - `Wiki`/`AnchorOnly` target with an anchor that matches neither a //! - `Wiki`/`AnchorOnly` target with an anchor that matches neither a
@@ -8,7 +8,7 @@
//! - `File`/`Local` target whose resolved path doesn't exist on disk. //! - `File`/`Local` target whose resolved path doesn't exist on disk.
//! //!
//! Interwiki, Diary, Raw, and external links are not diagnosed — //! Interwiki, Diary, Raw, and external links are not diagnosed —
//! interwiki resolution is Phase 18's job, diary is Phase 16's, and we //! interwiki resolution and diary handling live elsewhere, and we
//! don't fetch URLs. //! don't fetch URLs.
use std::path::PathBuf; use std::path::PathBuf;
@@ -332,8 +332,7 @@ fn resolve_file_path(
index.root.as_ref().map(|r| r.join(&candidate)) index.root.as_ref().map(|r| r.join(&candidate))
} }
/// `nuwiki.link` diagnostics for a single document. Phase 11 left this as /// `nuwiki.link` diagnostics for a single document.
/// a stub; Phase 15 fills it in.
pub fn link_health( pub fn link_health(
ast: &DocumentNode, ast: &DocumentNode,
text: &str, text: &str,
+1 -1
View File
@@ -1,4 +1,4 @@
//! Diary subsystem (Phase 16) — pure helpers used by the //! Diary subsystem — pure helpers used by the
//! `nuwiki.diary.*` `executeCommand` handlers. //! `nuwiki.diary.*` `executeCommand` handlers.
//! //!
//! All path/URI math lives here so the command dispatcher stays a thin //! All path/URI math lives here so the command dispatcher stays a thin
+1 -1
View File
@@ -1,6 +1,6 @@
//! Helpers for building `WorkspaceEdit`s. //! Helpers for building `WorkspaceEdit`s.
//! //!
//! Phase 13+ (rename, list/table edits, link/TOC generation) all return //! Rename, list/table edits, and link/TOC generation all return
//! `WorkspaceEdit`. The builder centralises the byte-offset → LSP-position //! `WorkspaceEdit`. The builder centralises the byte-offset → LSP-position
//! conversion (UTF-8 or UTF-16, matching whatever was negotiated in //! conversion (UTF-8 or UTF-16, matching whatever was negotiated in
//! `initialize`) so each call site doesn't have to redo it. //! `initialize`) so each call site doesn't have to redo it.
+1 -1
View File
@@ -1,4 +1,4 @@
//! HTML export — pure helpers shared by the Phase 17 `nuwiki.export.*` //! HTML export — pure helpers shared by the `nuwiki.export.*`
//! command handlers. Side-effecting work (reading the template from //! command handlers. Side-effecting work (reading the template from
//! disk, writing the rendered HTML, copying CSS) lives in `commands.rs` //! disk, writing the rendered HTML, copying CSS) lives in `commands.rs`
//! since the dispatcher is the one with a `&Backend` and async context. //! since the dispatcher is the one with a `&Backend` and async context.
+1 -1
View File
@@ -1,4 +1,4 @@
//! `textDocument/foldingRange` provider — SPEC §12.10 / P14. //! `textDocument/foldingRange` provider.
//! //!
//! Strategy: one fold per heading (line → line before the next heading //! Strategy: one fold per heading (line → line before the next heading
//! of same-or-higher level) plus one fold per top-level list block. //! of same-or-higher level) plus one fold per top-level list block.
+10 -10
View File
@@ -1,7 +1,7 @@
//! Workspace index — the in-memory model of every `.wiki` page nuwiki //! Workspace index — the in-memory model of every `.wiki` page nuwiki
//! has seen, used to power Phase 8 nav features. //! has seen, used to power nav features.
//! //!
//! Per P8 (SPEC §11), the initial scan runs as a background tokio task so //! The initial scan runs as a background tokio task so
//! the server stays responsive on startup. Per-document updates land here //! the server stays responsive on startup. Per-document updates land here
//! synchronously via `upsert` whenever the LSP backend re-parses on //! synchronously via `upsert` whenever the LSP backend re-parses on
//! `didOpen` / `didChange`. //! `didOpen` / `didChange`.
@@ -29,10 +29,10 @@ pub struct IndexedPage {
pub title: Option<String>, pub title: Option<String>,
pub headings: Vec<HeadingInfo>, pub headings: Vec<HeadingInfo>,
pub outgoing: Vec<OutgoingLink>, pub outgoing: Vec<OutgoingLink>,
/// Phase 12: every `TagNode` on the page. `tags_by_name` on the /// Every `TagNode` on the page. `tags_by_name` on the
/// containing `WorkspaceIndex` is the reverse map. /// containing `WorkspaceIndex` is the reverse map.
pub tags: Vec<TagInfo>, pub tags: Vec<TagInfo>,
/// Phase 16: `Some(date)` when this page is a *daily* diary entry — /// `Some(date)` when this page is a *daily* diary entry —
/// stem parses as `YYYY-MM-DD`. Equivalent to /// stem parses as `YYYY-MM-DD`. Equivalent to
/// `diary_period.and_then(|p| if let DiaryPeriod::Day(d) = p { Some(d) } else { None })`. /// `diary_period.and_then(|p| if let DiaryPeriod::Day(d) = p { Some(d) } else { None })`.
/// Kept as its own field for back-compat with prev/next/list callers /// Kept as its own field for back-compat with prev/next/list callers
@@ -97,7 +97,7 @@ pub struct Backlink {
} }
/// A tag's location, used by `WorkspaceIndex::tags_for` for cross-page /// A tag's location, used by `WorkspaceIndex::tags_for` for cross-page
/// `[[Page#tag]]` resolution and `nuwiki.tags.search` (Phase 13). /// `[[Page#tag]]` resolution and `nuwiki.tags.search`.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct TagOccurrence { pub struct TagOccurrence {
pub uri: Url, pub uri: Url,
@@ -108,7 +108,7 @@ pub struct TagOccurrence {
#[derive(Debug, Default)] #[derive(Debug, Default)]
pub struct WorkspaceIndex { pub struct WorkspaceIndex {
pub root: Option<PathBuf>, pub root: Option<PathBuf>,
/// Phase 16: subdir relative to `root` that holds diary entries. /// Subdir relative to `root` that holds diary entries.
/// Mirrors `WikiConfig::diary_rel_path`; stored here so `upsert` can /// Mirrors `WikiConfig::diary_rel_path`; stored here so `upsert` can
/// classify each indexed URI without a back-reference to the config. /// classify each indexed URI without a back-reference to the config.
pub diary_rel_path: Option<String>, pub diary_rel_path: Option<String>,
@@ -119,9 +119,9 @@ pub struct WorkspaceIndex {
pub pages_by_uri: HashMap<Url, IndexedPage>, pub pages_by_uri: HashMap<Url, IndexedPage>,
pub pages_by_name: HashMap<String, Url>, pub pages_by_name: HashMap<String, Url>,
pub backlinks: HashMap<String, Vec<Backlink>>, pub backlinks: HashMap<String, Vec<Backlink>>,
/// Phase 12: tag name → every occurrence across the workspace. Used by /// Tag name → every occurrence across the workspace. Used by
/// the v1.1 nav handlers (tag-as-anchor `definition`, `workspace/symbol` /// the nav handlers (tag-as-anchor `definition`, `workspace/symbol`
/// inclusion) and the Phase 13 `nuwiki.tags.search` command. /// inclusion) and the `nuwiki.tags.search` command.
pub tags_by_name: HashMap<String, Vec<TagOccurrence>>, pub tags_by_name: HashMap<String, Vec<TagOccurrence>>,
} }
@@ -187,7 +187,7 @@ impl WorkspaceIndex {
} }
} }
// Phase 12: maintain the reverse tag map alongside the per-page list. // Maintain the reverse tag map alongside the per-page list.
for tag in &page.tags { for tag in &page.tags {
self.tags_by_name self.tags_by_name
.entry(tag.name.clone()) .entry(tag.name.clone())
+26 -27
View File
@@ -1,8 +1,7 @@
//! LSP protocol bridge for nuwiki. //! LSP protocol bridge for nuwiki.
//! //!
//! `tower-lsp` server that maintains a `DocumentStore` (SPEC §6.10) plus a //! `tower-lsp` server that maintains a `DocumentStore` plus a
//! workspace-wide index (SPEC §6.10 + P8) and exposes every LSP method //! workspace-wide index and exposes every LSP method:
//! through Phase 8:
//! //!
//! - `initialize` / `initialized` / `shutdown` //! - `initialize` / `initialized` / `shutdown`
//! - `textDocument/didOpen`, `didChange` (full sync), `didClose` //! - `textDocument/didOpen`, `didChange` (full sync), `didClose`
@@ -14,12 +13,12 @@
//! - `workspace/symbol` — search across indexed headings //! - `workspace/symbol` — search across indexed headings
//! //!
//! Position encoding is negotiated as UTF-8 when the client supports LSP //! Position encoding is negotiated as UTF-8 when the client supports LSP
//! 3.17+ encodings (SPEC §6.11). When the client only supports the legacy //! 3.17+ encodings. When the client only supports the legacy
//! UTF-16 default, positions get translated through a per-line lookup. //! UTF-16 default, positions get translated through a per-line lookup.
//! //!
//! Re-parses are full per change (incremental parsing deferred post-v1). //! Re-parses are full per change (no incremental parsing).
//! The workspace scan runs in a background tokio task spawned from //! The workspace scan runs in a background tokio task spawned from
//! `initialize` (P8) with `window/workDoneProgress` begin/end events. //! `initialize` with `window/workDoneProgress` begin/end events.
pub mod commands; pub mod commands;
pub mod config; pub mod config;
@@ -111,8 +110,8 @@ pub async fn run_stdio() -> io::Result<()> {
struct DocumentState { struct DocumentState {
text: String, text: String,
ast: DocumentNode, ast: DocumentNode,
/// Last version we observed from the client. Held per SPEC §6.10 even /// Last version we observed from the client. Held even
/// though the foundation phase doesn't yet need to read it back. /// though nothing reads it back yet.
#[allow(dead_code)] #[allow(dead_code)]
version: i32, version: i32,
} }
@@ -124,8 +123,8 @@ struct Backend {
/// True after `initialize` if the client opted into UTF-8 encoding. /// True after `initialize` if the client opted into UTF-8 encoding.
/// Otherwise we keep the LSP 3.16 default of UTF-16. /// Otherwise we keep the LSP 3.16 default of UTF-16.
use_utf8: Arc<AtomicBool>, use_utf8: Arc<AtomicBool>,
/// Per-wiki state (P11). Vector holds one entry until Phase 18 /// Per-wiki state. Vector holds one entry until multi-wiki
/// (multi-wiki) lets users add more. /// support lets users add more.
wikis: Arc<RwLock<Vec<Wiki>>>, wikis: Arc<RwLock<Vec<Wiki>>>,
/// Server config received via `initializationOptions` and refreshed /// Server config received via `initializationOptions` and refreshed
/// via `workspace/didChangeConfiguration` (P18). /// via `workspace/didChangeConfiguration` (P18).
@@ -151,7 +150,7 @@ impl Backend {
wikis.iter().find(|w| w.id == id).cloned() wikis.iter().find(|w| w.id == id).cloned()
} }
/// Used by Phase 15 workspace-level commands (`checkLinks`, `findOrphans`) /// Used by workspace-level commands (`checkLinks`, `findOrphans`)
/// when no URI is supplied — they fall back to the first registered wiki. /// when no URI is supplied — they fall back to the first registered wiki.
fn default_wiki(&self) -> Option<Wiki> { fn default_wiki(&self) -> Option<Wiki> {
let wikis = self.wikis.read().ok()?; let wikis = self.wikis.read().ok()?;
@@ -290,7 +289,7 @@ impl Backend {
/// Return live state if `uri` is open in the editor, otherwise read /// Return live state if `uri` is open in the editor, otherwise read
/// the file from disk and re-parse. The closed-doc path is used by /// the file from disk and re-parse. The closed-doc path is used by
/// cross-document operations (Phase 13 `workspace/rename` and link /// cross-document operations (`workspace/rename` and link
/// health) that need accurate spans + text without trusting cached /// health) that need accurate spans + text without trusting cached
/// `WorkspaceIndex` data. /// `WorkspaceIndex` data.
pub async fn read_or_open(&self, uri: &Url) -> io::Result<DocSnapshot> { pub async fn read_or_open(&self, uri: &Url) -> io::Result<DocSnapshot> {
@@ -318,7 +317,7 @@ impl Backend {
} }
async fn update_document(&self, uri: Url, text: String, version: i32) { async fn update_document(&self, uri: Url, text: String, version: i32) {
// For Phase 6 we always treat unknown buffers as vimwiki — if/when // We always treat unknown buffers as vimwiki — if/when
// multi-syntax dispatch lands the pick should follow the URI's ext. // multi-syntax dispatch lands the pick should follow the URI's ext.
let plugin = match self.registry.get("vimwiki") { let plugin = match self.registry.get("vimwiki") {
Some(p) => p, Some(p) => p,
@@ -373,7 +372,7 @@ impl Backend {
#[tower_lsp::async_trait] #[tower_lsp::async_trait]
impl LanguageServer for Backend { impl LanguageServer for Backend {
async fn initialize(&self, params: InitializeParams) -> LspResult<InitializeResult> { async fn initialize(&self, params: InitializeParams) -> LspResult<InitializeResult> {
// SPEC §6.11: prefer UTF-8 if the client advertises support. // Prefer UTF-8 if the client advertises support.
let supports_utf8 = params let supports_utf8 = params
.capabilities .capabilities
.general .general
@@ -404,7 +403,7 @@ impl LanguageServer for Backend {
change: Some(TextDocumentSyncKind::FULL), change: Some(TextDocumentSyncKind::FULL),
will_save: None, will_save: None,
will_save_wait_until: None, will_save_wait_until: None,
// Phase 17 `auto_export` listens on `did_save`. Asking // `auto_export` listens on `did_save`. Asking
// for `include_text: false` since the document store // for `include_text: false` since the document store
// already mirrors the live buffer. // already mirrors the live buffer.
save: Some(TextDocumentSyncSaveOptions::SaveOptions(SaveOptions { save: Some(TextDocumentSyncSaveOptions::SaveOptions(SaveOptions {
@@ -502,9 +501,9 @@ impl LanguageServer for Backend {
async fn did_change_configuration(&self, params: DidChangeConfigurationParams) { async fn did_change_configuration(&self, params: DidChangeConfigurationParams) {
// Apply the new settings to `Config`. Re-indexing on root changes // Apply the new settings to `Config`. Re-indexing on root changes
// is out of scope for Phase 11; for now we just accept the new // is out of scope here; for now we just accept the new
// values so later commands see the right diagnostic settings etc. // values so later commands see the right diagnostic settings etc.
// Phase 18 (multi-wiki) revisits this with proper rebuild semantics. // Multi-wiki support revisits this with proper rebuild semantics.
let mut cfg = self.config.write().expect("config lock poisoned"); let mut cfg = self.config.write().expect("config lock poisoned");
cfg.apply_change(&params.settings); cfg.apply_change(&params.settings);
} }
@@ -530,7 +529,7 @@ impl LanguageServer for Backend {
} }
async fn did_rename_files(&self, params: RenameFilesParams) { async fn did_rename_files(&self, params: RenameFilesParams) {
// Phase 13 advertises this capability so the editor pushes // We advertise this capability so the editor pushes
// out-of-band renames (file explorer drags, `:VimwikiRenameFile` // out-of-band renames (file explorer drags, `:VimwikiRenameFile`
// when the client opts to surface the new URI). Update the index // when the client opts to surface the new URI). Update the index
// so backlinks/headings track the new URI without waiting for // so backlinks/headings track the new URI without waiting for
@@ -576,7 +575,7 @@ impl LanguageServer for Backend {
} }
async fn did_save(&self, params: DidSaveTextDocumentParams) { async fn did_save(&self, params: DidSaveTextDocumentParams) {
// Phase 17: when `auto_export` is set on the resolved wiki, run // When `auto_export` is set on the resolved wiki, run
// the equivalent of `nuwiki.export.currentToHtml` after the file // the equivalent of `nuwiki.export.currentToHtml` after the file
// hits disk. Best-effort — failures are logged but do not bubble // hits disk. Best-effort — failures are logged but do not bubble
// to the client. Skips pages with the `%nohtml` directive. // to the client. Skips pages with the `%nohtml` directive.
@@ -934,7 +933,7 @@ impl LanguageServer for Backend {
}); });
} }
} }
// Phase 12: surface tags too, named as `:tag:` so editors // Surface tags too, named as `:tag:` so editors
// can distinguish them from headings at a glance. // can distinguish them from headings at a glance.
for t in &page.tags { for t in &page.tags {
if q.is_empty() || t.name.to_lowercase().contains(&q) { if q.is_empty() || t.name.to_lowercase().contains(&q) {
@@ -1121,9 +1120,9 @@ fn utf16_column(text: &str, line: u32, byte_col: u32) -> u32 {
.sum::<usize>() as u32 .sum::<usize>() as u32
} }
/// Backward-compat wrapper kept for v1.0 test surface. New call sites /// Backward-compat wrapper kept for the legacy test surface. New call sites
/// should use `collect_diagnostics` so they can opt into link-health /// should use `collect_diagnostics` so they can opt into link-health
/// (Phase 15) and other sources. /// and other sources.
pub fn ast_diagnostics(ast: &DocumentNode, text: &str, utf8: bool) -> Vec<Diagnostic> { pub fn ast_diagnostics(ast: &DocumentNode, text: &str, utf8: bool) -> Vec<Diagnostic> {
collect_diagnostics( collect_diagnostics(
ast, ast,
@@ -1136,12 +1135,12 @@ pub fn ast_diagnostics(ast: &DocumentNode, text: &str, utf8: bool) -> Vec<Diagno
) )
} }
/// Composable diagnostic collector (P11 §12.2.4). /// Composable diagnostic collector.
/// ///
/// Sources, each gated by `cfg`: /// Sources, each gated by `cfg`:
/// ///
/// 1. Parse errors (always on). /// 1. Parse errors (always on).
/// 2. Broken-link warnings (Phase 15) — emitted when an index, source /// 2. Broken-link warnings — emitted when an index, source
/// page name, and `link_severity != Off` are all available. /// page name, and `link_severity != Off` are all available.
pub fn collect_diagnostics( pub fn collect_diagnostics(
ast: &DocumentNode, ast: &DocumentNode,
@@ -1317,8 +1316,8 @@ fn keyword_str(k: nuwiki_core::ast::Keyword) -> &'static str {
// ===== Workspace + navigation helpers ===== // ===== Workspace + navigation helpers =====
// //
// `workspace_root_from_params` lived here through Phase 10; Phase 11 lifted // `workspace_root_from_params` logic now lives in
// that logic into `Config::from_init_params` (see `config.rs`) so workspace // `Config::from_init_params` (see `config.rs`) so workspace
// folders and `root_uri` are handled alongside `initializationOptions`. // folders and `root_uri` are handled alongside `initializationOptions`.
/// Background workspace scan (P8). Walks every `.wiki` file under `root`, /// Background workspace scan (P8). Walks every `.wiki` file under `root`,
@@ -1469,7 +1468,7 @@ fn heading_range_in(idx: &WorkspaceIndex, uri: &Url, anchor: &str, _utf8: bool)
if let Some(h) = page.find_heading_by_anchor(anchor) { if let Some(h) = page.find_heading_by_anchor(anchor) {
return Some(span_to_lsp_range_no_text(&h.span)); return Some(span_to_lsp_range_no_text(&h.span));
} }
// Phase 12: tags-as-anchors. `[[Page#tag-name]]` lands on the // Tags-as-anchors. `[[Page#tag-name]]` lands on the
// matching `TagNode` on the target page. // matching `TagNode` on the target page.
let t = page.tags.iter().find(|t| t.name == anchor)?; let t = page.tags.iter().find(|t| t.name == anchor)?;
Some(span_to_lsp_range_no_text(&t.span)) Some(span_to_lsp_range_no_text(&t.span))
+1 -1
View File
@@ -1,4 +1,4 @@
//! Helpers shared by the Phase 8 navigation handlers (definition, //! Helpers shared by the navigation handlers (definition,
//! references, hover, completion). //! references, hover, completion).
//! //!
//! Two responsibilities: //! Two responsibilities:
+2 -2
View File
@@ -1,12 +1,12 @@
//! `textDocument/rename` — server-side rename with cross-document link //! `textDocument/rename` — server-side rename with cross-document link
//! rewriting. //! rewriting.
//! //!
//! Phase 13 implementation: //! Implementation:
//! 1. Resolve the rename target — cursor on a wikilink renames the linked //! 1. Resolve the rename target — cursor on a wikilink renames the linked
//! page; otherwise rename the current file. //! page; otherwise rename the current file.
//! 2. Build the new URI from the wiki root + the user-supplied path. //! 2. Build the new URI from the wiki root + the user-supplied path.
//! 3. For every page in the wiki's index with an outgoing link to the //! 3. For every page in the wiki's index with an outgoing link to the
//! old page name, read its source via the Phase 11 closed-doc loader //! old page name, read its source via the closed-doc loader
//! so cached spans are validated against current text, then emit a //! so cached spans are validated against current text, then emit a
//! `TextEdit` that swaps the `[[old]]` for `[[new]]` while preserving //! `TextEdit` that swaps the `[[old]]` for `[[new]]` while preserving
//! anchors and descriptions. //! anchors and descriptions.
+2 -3
View File
@@ -1,8 +1,7 @@
//! Semantic-token emission for the vimwiki AST. //! Semantic-token emission for the vimwiki AST.
//! //!
//! Token-type scheme settled in P7: custom `vimwiki*` types plus //! Token-type scheme: custom `vimwiki*` types plus
//! `level1`..`level6` + `centered` modifiers (SPEC §11). Default highlight //! `level1`..`level6` + `centered` modifiers.
//! groups for these arrive in Phase 9.
//! //!
//! Pipeline: //! Pipeline:
//! //!
+4 -4
View File
@@ -1,9 +1,9 @@
//! `Wiki` aggregate — per-wiki state, even when there's only one. //! `Wiki` aggregate — per-wiki state, even when there's only one.
//! //!
//! v1.0 shipped with a single `Arc<RwLock<WorkspaceIndex>>` on `Backend`. //! The single `Arc<RwLock<WorkspaceIndex>>` on `Backend` is lifted into a
//! Phase 11 lifts that into a `Wiki` aggregate so Phase 18 (multi-wiki) //! `Wiki` aggregate so multi-wiki support only changes config shape, not
//! only changes config shape, not data flow. Phases 1217 always operate //! data flow. Handlers always operate on a `Wiki`; in practice the `wikis`
//! on a `Wiki`; in practice the `wikis` `Vec` has one entry until 18. //! `Vec` has one entry until multi-wiki support lands.
use std::path::Path; use std::path::Path;
use std::sync::{Arc, RwLock}; use std::sync::{Arc, RwLock};
@@ -47,6 +47,24 @@ fn cycle_state_walks_progression() {
assert_eq!(ops::cycle_state("[-]"), Some("[ ]")); assert_eq!(ops::cycle_state("[-]"), Some("[ ]"));
} }
#[test]
fn cycle_state_back_is_exact_inverse() {
// `glp` walks the progression in reverse — every step must undo the
// matching `cycle_state` step.
assert_eq!(ops::cycle_state_back("[ ]"), Some("[X]"));
assert_eq!(ops::cycle_state_back("[X]"), Some("[O]"));
assert_eq!(ops::cycle_state_back("[O]"), Some("[o]"));
assert_eq!(ops::cycle_state_back("[o]"), Some("[.]"));
assert_eq!(ops::cycle_state_back("[.]"), Some("[ ]"));
assert_eq!(ops::cycle_state_back("[-]"), Some("[ ]"));
// Composing forward then backward returns to the start for every
// in-cycle marker.
for s in ["[ ]", "[.]", "[o]", "[O]", "[X]"] {
let fwd = ops::cycle_state(s).unwrap();
assert_eq!(ops::cycle_state_back(fwd), Some(s));
}
}
#[test] #[test]
fn reject_state_toggles_dash_marker() { fn reject_state_toggles_dash_marker() {
assert_eq!(ops::reject_state("[ ]"), Some("[-]")); assert_eq!(ops::reject_state("[ ]"), Some("[-]"));
+2 -2
View File
@@ -1,9 +1,9 @@
//! §13.1 §13.3 — `color_dic` → `HtmlRenderer` integration. //! `color_dic` → `HtmlRenderer` integration.
//! //!
//! Drives the renderer directly so we don't have to spin up the full //! Drives the renderer directly so we don't have to spin up the full
//! export pipeline. The end-to-end path (export command → renderer //! export pipeline. The end-to-end path (export command → renderer
//! with `cfg.color_dic` plumbed through) is integration-tested via the //! with `cfg.color_dic` plumbed through) is integration-tested via the
//! Phase 17 export tests. //! export tests.
use std::collections::HashMap; use std::collections::HashMap;
@@ -0,0 +1,532 @@
//! Command-coverage suite: at least one behavioural test for every
//! documented `:Nuwiki*` / `:Vimwiki*` command (see the COMMANDS section
//! of `doc/nuwiki.txt`).
//!
//! The user-facing commands funnel through `workspace/executeCommand`
//! into the Backend dispatcher, or — for follow/backlinks/rename — through
//! the standard LSP requests. The Backend itself owns a live `Client` and
//! can't be built in an integration test, so each case drives the *pure*
//! building-block the command relies on (the same functions the dispatcher
//! wraps). This file is organised to mirror the doc's command groups so a
//! reader can confirm every command has coverage.
use std::collections::HashMap;
use std::path::PathBuf;
use nuwiki_core::ast::{InlineNode, LinkKind, LinkTarget};
use nuwiki_core::date::DiaryDate;
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
use nuwiki_core::syntax::SyntaxPlugin;
use nuwiki_lsp::commands::{export_ops, ops, COMMANDS};
use nuwiki_lsp::config::WikiConfig;
use nuwiki_lsp::index::WorkspaceIndex;
use nuwiki_lsp::nav::find_inline_at;
use nuwiki_lsp::rename::{build_new_uri, rewrite_wikilink_target};
use nuwiki_lsp::wiki::{build_wikis, resolve_uri_to_wiki};
use nuwiki_lsp::{diary, export};
use tower_lsp::lsp_types::Url;
fn parse(src: &str) -> nuwiki_core::ast::DocumentNode {
VimwikiSyntax::new().parse(src)
}
fn wiki_cfg(root: &str) -> WikiConfig {
WikiConfig::from_root(PathBuf::from(root))
}
/// Build a workspace index rooted at `root` with the given `name -> source`
/// pages, mirroring the helper used by the other command suites.
fn build_index(root: &str, pages: &[(&str, &str)]) -> WorkspaceIndex {
let mut idx = WorkspaceIndex::new(Some(PathBuf::from(root)));
for (name, src) in pages {
let uri = Url::from_file_path(format!("{root}/{name}.wiki")).unwrap();
idx.upsert(uri, &parse(src));
}
idx
}
fn diary_index(root: &str, dates: &[&str]) -> WorkspaceIndex {
let mut idx =
WorkspaceIndex::new(Some(PathBuf::from(root))).with_diary_rel_path(Some("diary".into()));
for d in dates {
let uri = Url::from_file_path(format!("{root}/diary/{d}.wiki")).unwrap();
idx.upsert(uri, &parse("= entry =\n"));
}
idx
}
// =====================================================================
// Group 1: Wiki / navigation
// =====================================================================
// :NuwikiIndex — open wiki N's index page.
#[test]
fn nuwiki_index_resolves_index_page() {
let idx = build_index("/wiki", &[("index", "= Home =\n"), ("Other", "= O =\n")]);
let page = idx.page_by_name("index").expect("index page indexed");
assert!(page.uri.as_str().ends_with("/wiki/index.wiki"));
}
// :NuwikiTabIndex — same target as :NuwikiIndex, opened in a new tab.
// The tab-vs-current split is an editor concern; the resolved target is
// identical, and both verbs are advertised as executeCommands.
#[test]
fn nuwiki_tab_index_advertised_alongside_index() {
assert!(COMMANDS.contains(&"nuwiki.wiki.openIndex"));
assert!(COMMANDS.contains(&"nuwiki.wiki.tabOpenIndex"));
}
// :NuwikiUISelect — pick a wiki from the configured list.
#[test]
fn nuwiki_ui_select_lists_configured_wikis() {
let cfgs = vec![wiki_cfg("/a/personal"), wiki_cfg("/b/work")];
let wikis = build_wikis(&cfgs);
assert_eq!(wikis.len(), 2);
assert_eq!(wikis[0].config.name, "personal");
assert_eq!(wikis[1].config.name, "work");
// Selection by URI picks the wiki that actually owns the file.
let uri = Url::from_file_path("/b/work/Page.wiki").unwrap();
assert_eq!(resolve_uri_to_wiki(&wikis, &uri), Some(wikis[1].id));
}
// :NuwikiGoto {page} — open `{page}.wiki` by name.
#[test]
fn nuwiki_goto_opens_page_by_name() {
let idx = build_index("/wiki", &[("index", "= H =\n"), ("Recipes", "= R =\n")]);
let page = idx
.page_by_name("Recipes")
.expect("page resolvable by name");
assert!(page.uri.as_str().ends_with("/wiki/Recipes.wiki"));
assert!(idx.page_by_name("Missing").is_none());
}
// :NuwikiFollowLink — follow the link under the cursor.
#[test]
fn nuwiki_follow_link_resolves_target_under_cursor() {
let idx = build_index(
"/wiki",
&[("Home", "see [[About]]\n"), ("About", "= A =\n")],
);
let doc = parse("see [[About]]\n");
// Byte col 8 sits inside "[[About]]" (starts at byte 4).
let node = find_inline_at(&doc, 0, 8).expect("wikilink under cursor");
let InlineNode::WikiLink(w) = node else {
panic!("expected a wikilink, got {node:?}");
};
let about = Url::from_file_path("/wiki/About.wiki").unwrap();
assert_eq!(idx.resolve(&w.target, "Home"), Some(&about));
}
// :NuwikiBacklinks — every reference to the current page.
#[test]
fn nuwiki_backlinks_lists_referrers() {
let idx = build_index(
"/wiki",
&[
("Home", "[[About]]\n"),
("Contact", "see [[About]] too\n"),
("About", "= A =\n"),
],
);
let back = idx.backlinks_for("About");
assert_eq!(back.len(), 2);
assert!(idx.backlinks_for("Home").is_empty());
}
// :NuwikiNextLink / :NuwikiPrevLink — jump to the next / previous wikilink.
// The jump itself is editor-side cursor movement; what the server provides
// is the ordered set of links on the page.
#[test]
fn nuwiki_next_prev_link_walk_links_in_document_order() {
let doc = parse("intro [[First]] mid [[Second]] end [[Third]]\n");
let links = ops::collect_wiki_links(&doc);
let targets: Vec<&str> = links
.iter()
.filter_map(|l| l.target.path.as_deref())
.collect();
assert_eq!(targets, vec!["First", "Second", "Third"]);
}
// :NuwikiBaddLink — add the target of the link under the cursor to the
// buffer list. The editor :badds whatever URI the link resolves to.
#[test]
fn nuwiki_badd_link_resolves_target_uri() {
let idx = build_index(
"/wiki",
&[("Home", "[[Notes/Todo]]\n"), ("Notes/Todo", "= T =\n")],
);
let target = LinkTarget {
kind: LinkKind::Wiki,
path: Some("Notes/Todo".into()),
..Default::default()
};
let expected = Url::from_file_path("/wiki/Notes/Todo.wiki").unwrap();
assert_eq!(idx.resolve(&target, "Home"), Some(&expected));
}
// :NuwikiRenameFile — rename the page and rewrite every inbound link.
#[test]
fn nuwiki_rename_file_builds_uri_and_rewrites_links() {
let new_uri = build_new_uri(&PathBuf::from("/wiki"), "New Name", ".wiki").unwrap();
assert!(new_uri.as_str().ends_with("/wiki/New%20Name.wiki"));
assert_eq!(
rewrite_wikilink_target("[[Old Name|caption]]", "New Name"),
Some("[[New Name|caption]]".to_string())
);
}
// :NuwikiDeleteFile — delete the current page and its on-disk file.
#[test]
fn nuwiki_delete_file_emits_delete_workspace_edit() {
use tower_lsp::lsp_types::{DocumentChangeOperation, DocumentChanges, ResourceOp};
let uri = Url::parse("file:///wiki/Doomed.wiki").unwrap();
let mut b = nuwiki_lsp::edits::WorkspaceEditBuilder::new();
b.file_op(nuwiki_lsp::edits::op_delete(uri.clone()));
let we = b.build();
let DocumentChanges::Operations(ops) = we.document_changes.unwrap() else {
panic!("expected resource operations");
};
assert!(matches!(
&ops[0],
DocumentChangeOperation::Op(ResourceOp::Delete(d)) if d.uri == uri
));
}
// =====================================================================
// Group 2: Diary
// =====================================================================
// :NuwikiMakeDiaryNote — open today's entry.
#[test]
fn nuwiki_make_diary_note_targets_todays_file() {
let cfg = wiki_cfg("/wiki");
let today = DiaryDate::from_ymd(2026, 5, 12).unwrap();
let uri = diary::uri_for_date(&cfg, &today).unwrap();
assert!(uri.as_str().ends_with("/diary/2026-05-12.wiki"));
}
// :NuwikiMakeYesterdayDiaryNote / :NuwikiMakeTomorrowDiaryNote — step the
// diary back / forward by one period at the daily cadence.
#[test]
fn nuwiki_yesterday_and_tomorrow_target_adjacent_files() {
let cfg = wiki_cfg("/wiki");
let yesterday = DiaryDate::from_ymd(2026, 5, 11).unwrap();
let tomorrow = DiaryDate::from_ymd(2026, 5, 13).unwrap();
assert!(diary::uri_for_date(&cfg, &yesterday)
.unwrap()
.as_str()
.ends_with("/diary/2026-05-11.wiki"));
assert!(diary::uri_for_date(&cfg, &tomorrow)
.unwrap()
.as_str()
.ends_with("/diary/2026-05-13.wiki"));
}
// :NuwikiDiaryIndex — open the diary index page.
#[test]
fn nuwiki_diary_index_resolves_index_uri() {
let cfg = wiki_cfg("/wiki");
let uri = diary::index_uri(&cfg).unwrap();
assert!(uri.as_str().ends_with("/diary/diary.wiki"));
}
// :NuwikiDiaryGenerateLinks — rebuild the diary index from disk entries.
#[test]
fn nuwiki_diary_generate_links_builds_grouped_body() {
let idx = diary_index("/wiki", &["2026-05-10", "2026-05-12", "2026-04-30"]);
let entries = diary::list_entries(&idx);
let body = diary::build_index_body(&entries, "diary", "Diary");
assert!(body.starts_with("= Diary ="));
assert!(body.contains("2026-05-12"));
assert!(body.contains("2026-05-10"));
assert!(body.contains("2026-04-30"));
// Newest entry appears before the older one within the same month.
let p12 = body.find("2026-05-12").unwrap();
let p10 = body.find("2026-05-10").unwrap();
assert!(p12 < p10, "expected newest-first ordering");
}
// :NuwikiDiaryNextDay / :NuwikiDiaryPrevDay — walk indexed entries.
#[test]
fn nuwiki_diary_next_and_prev_day_walk_entries() {
let idx = diary_index("/wiki", &["2026-05-10", "2026-05-12", "2026-05-15"]);
let from = DiaryDate::from_ymd(2026, 5, 12).unwrap();
assert_eq!(
diary::next_entry(&idx, &from).unwrap().date.format(),
"2026-05-15"
);
assert_eq!(
diary::prev_entry(&idx, &from).unwrap().date.format(),
"2026-05-10"
);
}
// =====================================================================
// Group 3: Page generation
// =====================================================================
// :NuwikiTOC — generate / refresh the table of contents.
#[test]
fn nuwiki_toc_generates_nested_contents() {
let src = "= Top =\n== Sub ==\ntext\n";
let doc = parse(src);
let uri = Url::from_file_path("/wiki/Page.wiki").unwrap();
let edit = ops::toc_edit(src, &doc, &uri, true).expect("toc edit produced");
assert!(edit.changes.is_some() || edit.document_changes.is_some());
let toc = ops::build_toc_text(
&[
(1, "Top".into(), "top".into()),
(2, "Sub".into(), "sub".into()),
],
"Contents",
);
assert!(toc.contains("= Contents ="));
assert!(toc.contains("- [[#top|Top]]"));
assert!(toc.contains(" - [[#sub|Sub]]"));
}
// :NuwikiGenerateLinks — insert a flat list of every page in the wiki.
#[test]
fn nuwiki_generate_links_lists_all_pages_excluding_current() {
let pages = vec!["About".to_string(), "Home".to_string(), "Notes".to_string()];
let text = ops::build_links_text(&pages, "Links", Some("Home"));
assert!(text.contains("= Links ="));
assert!(text.contains("- [[About]]"));
assert!(text.contains("- [[Notes]]"));
assert!(!text.contains("[[Home]]"), "current page excluded");
let src = "= Existing =\n";
let doc = parse(src);
let uri = Url::from_file_path("/wiki/Home.wiki").unwrap();
assert!(ops::links_edit(src, &doc, &uri, "Home", &pages, true).is_some());
}
// :NuwikiCheckLinks — surface broken links across the workspace.
#[test]
fn nuwiki_check_links_distinguishes_broken_from_resolvable() {
let idx = build_index(
"/wiki",
&[("Home", "[[About]] [[Ghost]]\n"), ("About", "= A =\n")],
);
let good = LinkTarget {
kind: LinkKind::Wiki,
path: Some("About".into()),
..Default::default()
};
let broken = LinkTarget {
kind: LinkKind::Wiki,
path: Some("Ghost".into()),
..Default::default()
};
assert!(idx.resolve(&good, "Home").is_some());
assert!(
idx.resolve(&broken, "Home").is_none(),
"broken link unresolved"
);
}
// =====================================================================
// Group 4: Tags
// =====================================================================
// :NuwikiSearchTags {tag} — every `:tag:` occurrence.
#[test]
fn nuwiki_search_tags_finds_occurrences() {
let idx = build_index(
"/wiki",
&[("Home", ":alpha:beta:\n"), ("Work", ":alpha:\n")],
);
let hits = ops::tag_hits(&idx, "alpha");
assert_eq!(hits.len(), 2);
assert!(hits.iter().all(|h| h.name == "alpha"));
assert!(ops::tag_hits(&idx, "nonexistent").is_empty());
}
// :NuwikiGenerateTagLinks [tag] — section linking every page with a tag.
#[test]
fn nuwiki_generate_tag_links_builds_section() {
let idx = build_index(
"/wiki",
&[
("Home", ":alpha:\n"),
("Work", ":alpha:\n"),
("Misc", ":beta:\n"),
],
);
let by_tag = ops::tag_pages_snapshot(&idx);
let single = ops::build_tag_links_text(&by_tag, Some("alpha")).unwrap();
assert!(single.starts_with("= Tag: alpha ="));
assert!(single.contains("- [[Home]]"));
assert!(single.contains("- [[Work]]"));
// No-arg variant emits a section per tag.
let all = ops::build_tag_links_text(&by_tag, None).unwrap();
assert!(all.starts_with("= Tags ="));
assert!(all.contains("== alpha =="));
assert!(all.contains("== beta =="));
let src = "= Home =\n";
let doc = parse(src);
let uri = Url::from_file_path("/wiki/Home.wiki").unwrap();
assert!(ops::tag_links_edit(src, &doc, &uri, Some("alpha"), &by_tag, true).is_some());
}
// :NuwikiRebuildTags — force a full workspace re-index. A rebuild must
// reflect the current on-disk tags, dropping stale ones.
#[test]
fn nuwiki_rebuild_tags_reflects_current_state() {
let mut idx = WorkspaceIndex::new(Some(PathBuf::from("/wiki")));
let uri = Url::from_file_path("/wiki/Home.wiki").unwrap();
idx.upsert(uri.clone(), &parse(":old:\n"));
assert_eq!(idx.tags_for("old").len(), 1);
// Re-index after the page changed on disk.
idx.upsert(uri, &parse(":new:\n"));
assert!(idx.tags_for("old").is_empty(), "stale tag dropped");
assert_eq!(idx.tags_for("new").len(), 1);
}
// =====================================================================
// Group 5: HTML export
// =====================================================================
// :Nuwiki2HTML — render the current page to HTML.
#[test]
fn nuwiki_2html_renders_page() {
let cfg = wiki_cfg("/wiki");
let doc = parse("%title My Page\n= One =\nbody text\n");
let html = export::render_page_html(
&doc,
Some("<title>{{title}}</title><main>{{content}}</main>".into()),
"Home",
DiaryDate::from_ymd(2026, 5, 12).unwrap(),
&cfg.html,
Some(".wiki"),
HashMap::new(),
)
.unwrap();
assert!(html.contains("<title>My Page</title>"));
assert!(html.contains("body text"));
}
// :Nuwiki2HTMLBrowse — render then open in the browser. The render output
// is identical to :Nuwiki2HTML; the browse step is an editor-side open.
#[test]
fn nuwiki_2html_browse_shares_render_and_is_advertised() {
assert!(COMMANDS.contains(&"nuwiki.export.browse"));
let cfg = wiki_cfg("/wiki");
let doc = parse("= Page =\nhi\n");
let html = export::render_page_html(
&doc,
Some("{{content}}".into()),
"Page",
DiaryDate::today_utc(),
&cfg.html,
Some(".wiki"),
HashMap::new(),
)
.unwrap();
assert!(html.contains("hi"));
}
// :NuwikiAll2HTML[!] — export every page; honour the exclude list and
// per-page output paths.
#[test]
fn nuwiki_all2html_maps_output_paths_and_excludes() {
let cfg = wiki_cfg("/wiki");
assert!(export::output_path_for(&cfg.html, "Home").ends_with("Home.html"));
assert!(
export::output_path_for(&cfg.html, "diary/2026-05-12").ends_with("diary/2026-05-12.html")
);
let patterns = vec!["_drafts/**".to_string()];
assert!(export::is_excluded(&patterns, "_drafts/secret"));
assert!(!export::is_excluded(&patterns, "Home"));
// Both incremental and forced (`!`) variants are advertised.
assert!(COMMANDS.contains(&"nuwiki.export.allToHtml"));
assert!(COMMANDS.contains(&"nuwiki.export.allToHtmlForce"));
}
// :NuwikiRss — write `rss.xml` summarising recent diary entries.
#[test]
fn nuwiki_rss_writes_feed_file() {
let root = std::env::temp_dir().join(format!("nuwiki-cov-rss-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let cfg = WikiConfig::from_root(root.clone());
let entries = vec![
diary::DiaryEntry {
date: DiaryDate::from_ymd(2026, 5, 10).unwrap(),
uri: Url::from_file_path(root.join("diary/2026-05-10.wiki")).unwrap(),
},
diary::DiaryEntry {
date: DiaryDate::from_ymd(2026, 5, 12).unwrap(),
uri: Url::from_file_path(root.join("diary/2026-05-12.wiki")).unwrap(),
},
];
let path = export_ops::write_rss(&cfg, &entries).unwrap();
assert!(path.ends_with("rss.xml"));
let xml = std::fs::read_to_string(&path).unwrap();
assert!(xml.contains("<rss version=\"2.0\">"));
// Newest entry listed first.
let p12 = xml.find("2026-05-12").unwrap();
let p10 = xml.find("2026-05-10").unwrap();
assert!(p12 < p10);
let _ = std::fs::remove_dir_all(&root);
}
// =====================================================================
// Group 6: Other
// =====================================================================
// :NuwikiInstall — install/re-install the `nuwiki-ls` binary. This is an
// editor-side action (binary download / cargo build); it is deliberately
// NOT an LSP executeCommand, so the server must not advertise it.
#[test]
fn nuwiki_install_is_editor_only_not_an_lsp_command() {
assert!(!COMMANDS.iter().any(|c| c.contains("install")));
}
// =====================================================================
// Command-surface cross-check: every documented command that maps to an
// executeCommand handler is actually advertised by the server.
// =====================================================================
#[test]
fn every_documented_lsp_command_is_advertised() {
let expected = [
"nuwiki.wiki.openIndex", // :NuwikiIndex
"nuwiki.wiki.tabOpenIndex", // :NuwikiTabIndex
"nuwiki.wiki.listAll", // :NuwikiUISelect
"nuwiki.wiki.gotoPage", // :NuwikiGoto
"nuwiki.file.delete", // :NuwikiDeleteFile
"nuwiki.diary.openToday", // :NuwikiMakeDiaryNote
"nuwiki.diary.openYesterday", // :NuwikiMakeYesterdayDiaryNote
"nuwiki.diary.openTomorrow", // :NuwikiMakeTomorrowDiaryNote
"nuwiki.diary.openIndex", // :NuwikiDiaryIndex
"nuwiki.diary.generateIndex", // :NuwikiDiaryGenerateLinks
"nuwiki.diary.next", // :NuwikiDiaryNextDay
"nuwiki.diary.prev", // :NuwikiDiaryPrevDay
"nuwiki.toc.generate", // :NuwikiTOC
"nuwiki.links.generate", // :NuwikiGenerateLinks
"nuwiki.workspace.checkLinks", // :NuwikiCheckLinks
"nuwiki.tags.search", // :NuwikiSearchTags
"nuwiki.tags.generateLinks", // :NuwikiGenerateTagLinks
"nuwiki.tags.rebuild", // :NuwikiRebuildTags
"nuwiki.export.currentToHtml", // :Nuwiki2HTML
"nuwiki.export.browse", // :Nuwiki2HTMLBrowse
"nuwiki.export.allToHtml", // :NuwikiAll2HTML
"nuwiki.export.rss", // :NuwikiRss
];
for cmd in expected {
assert!(COMMANDS.contains(&cmd), "server must advertise {cmd}");
}
}
+3 -3
View File
@@ -1,6 +1,6 @@
//! Phase 13: pure-function tests for the rename helper + the file-delete //! Pure-function tests for the rename helper + the file-delete
//! command. The async `Backend::rename` end-to-end (with `read_or_open` //! command. The async `Backend::rename` end-to-end (with `read_or_open`
//! hitting disk) is exercised by Phase 14+ integration but covered here //! hitting disk) is exercised by integration tests but covered here
//! at the building-block level — same logic, no async client. //! at the building-block level — same logic, no async client.
use std::path::PathBuf; use std::path::PathBuf;
@@ -95,7 +95,7 @@ async fn file_delete_returns_workspace_edit_with_delete_op() {
// //
// Easier: shape the test as an integration test over the public // Easier: shape the test as an integration test over the public
// module surface — call `commands::execute` once we expose a thin // module surface — call `commands::execute` once we expose a thin
// builder. For Phase 13 we test the JSON-shaped contract using the // builder. We test the JSON-shaped contract using the
// edits module directly, since that's what `file_delete` returns. // edits module directly, since that's what `file_delete` returns.
let uri = Url::parse("file:///tmp/note.wiki").unwrap(); let uri = Url::parse("file:///tmp/note.wiki").unwrap();
let mut b = nuwiki_lsp::edits::WorkspaceEditBuilder::new(); let mut b = nuwiki_lsp::edits::WorkspaceEditBuilder::new();
+36 -6
View File
@@ -1,4 +1,4 @@
//! §13.1 Cluster A — list rewriters: `removeDone`, `renumber`, //! Cluster A — list rewriters: `removeDone`, `renumber`,
//! `changeSymbol`, `changeLevel`. //! `changeSymbol`, `changeLevel`.
use nuwiki_core::ast::ListSymbol; use nuwiki_core::ast::ListSymbol;
@@ -86,10 +86,10 @@ fn remove_done_returns_none_when_no_lists() {
} }
#[test] #[test]
fn remove_done_scoped_by_position_to_one_item() { fn remove_done_scoped_by_position_to_current_list() {
// With `position` set, only items whose line range contains the // With `position` set, the scope is the contiguous list block the
// cursor line are considered. So a done item on line 1 is removed // cursor sits in — every done item in that list is removed, not just
// by cursor on line 1, but a done item on line 3 is left alone. // the one under the cursor.
let src = "- [X] done one\n- [ ] todo\n- [X] done two\n"; let src = "- [X] done one\n- [ ] todo\n- [X] done two\n";
let ast = parse(src); let ast = parse(src);
let pos = Some(LspPosition { let pos = Some(LspPosition {
@@ -98,7 +98,37 @@ fn remove_done_scoped_by_position_to_one_item() {
}); });
let edit = ops::remove_done_edit(src, &ast, &uri(), pos, true).expect("edit"); let edit = ops::remove_done_edit(src, &ast, &uri(), pos, true).expect("edit");
let edits = &edit.changes.unwrap()[&uri()]; let edits = &edit.changes.unwrap()[&uri()];
assert_eq!(edits.len(), 1, "only the line-0 item should match"); assert_eq!(edits.len(), 2, "both done items in the current list match");
}
#[test]
fn remove_done_position_leaves_other_lists_untouched() {
// Two separate list blocks split by a paragraph. Cursor in the first
// list removes only that list's done items; the second list's done
// item survives.
let src = "- [X] done a\n- [ ] todo a\n\nparagraph\n\n- [ ] todo b\n- [X] done b\n";
let ast = parse(src);
let pos = Some(LspPosition {
line: 0,
character: 0,
});
let edit = ops::remove_done_edit(src, &ast, &uri(), pos, true).expect("edit");
let edits = &edit.changes.unwrap()[&uri()];
assert_eq!(edits.len(), 1, "only the first list's done item is removed");
}
#[test]
fn remove_done_position_cascades_into_sublists() {
// The current-list scope includes nested sublists of the block.
let src = "- [ ] parent\n - [X] done child\n - [ ] open child\n";
let ast = parse(src);
let pos = Some(LspPosition {
line: 0,
character: 0,
});
let edit = ops::remove_done_edit(src, &ast, &uri(), pos, true).expect("edit");
let edits = &edit.changes.unwrap()[&uri()];
assert_eq!(edits.len(), 1, "the nested done child is removed");
} }
// ===== renumber ===== // ===== renumber =====
+1 -1
View File
@@ -1,4 +1,4 @@
//! §13.1 Cluster B — table rewriters: `insert`, `align`, `moveColumn`. //! Cluster B — table rewriters: `insert`, `align`, `moveColumn`.
use nuwiki_core::syntax::vimwiki::VimwikiSyntax; use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
use nuwiki_core::syntax::SyntaxPlugin; use nuwiki_core::syntax::SyntaxPlugin;
+1 -1
View File
@@ -337,7 +337,7 @@ fn diary_entry_serializes_to_date_string_and_uri() {
// ===== COMMANDS list completeness ===== // ===== COMMANDS list completeness =====
#[test] #[test]
fn commands_list_includes_phase16_entries() { fn commands_list_includes_diary_entries() {
let names: Vec<&str> = nuwiki_lsp::commands::COMMANDS.to_vec(); let names: Vec<&str> = nuwiki_lsp::commands::COMMANDS.to_vec();
for name in [ for name in [
"nuwiki.diary.openToday", "nuwiki.diary.openToday",
+1 -1
View File
@@ -1,4 +1,4 @@
//! Phase 19: LSP folding-range provider. //! LSP folding-range provider.
//! //!
//! Drives `folding::folding_ranges` directly. The handler wiring is //! Drives `folding::folding_ranges` directly. The handler wiring is
//! shallow — it just calls into this function — so we verify the //! shallow — it just calls into this function — so we verify the
+2 -2
View File
@@ -1,7 +1,7 @@
//! Tests for the pure helpers that the LSP backend uses to translate //! Tests for the pure helpers that the LSP backend uses to translate
//! between `nuwiki-core` ASTs and the LSP wire format. The async //! between `nuwiki-core` ASTs and the LSP wire format. The async
//! request-handling loop is exercised at integration time in Phase 8+ //! request-handling loop is exercised at integration time by the
//! (navigation), so this file stays focused on conversion correctness. //! navigation tests, so this file stays focused on conversion correctness.
use nuwiki_core::ast::{ use nuwiki_core::ast::{
inline::InlineNode, BlockNode, BlockquoteNode, DocumentNode, ErrorNode, ParagraphNode, inline::InlineNode, BlockNode, BlockquoteNode, DocumentNode, ErrorNode, ParagraphNode,
+2 -2
View File
@@ -1,4 +1,4 @@
//! Phase 17: HTML export commands. //! HTML export commands.
//! //!
//! Drives the pure `export::*` helpers directly. The side-effecting //! Drives the pure `export::*` helpers directly. The side-effecting
//! `export_ops::write_page` / `write_rss` paths get exercised via a //! `export_ops::write_page` / `write_rss` paths get exercised via a
@@ -474,7 +474,7 @@ fn write_rss_emits_valid_xml_with_entries() {
// ===== COMMANDS list completeness ===== // ===== COMMANDS list completeness =====
#[test] #[test]
fn commands_list_includes_phase17_entries() { fn commands_list_includes_export_entries() {
let names: Vec<&str> = nuwiki_lsp::commands::COMMANDS.to_vec(); let names: Vec<&str> = nuwiki_lsp::commands::COMMANDS.to_vec();
for name in [ for name in [
"nuwiki.export.currentToHtml", "nuwiki.export.currentToHtml",
+2 -2
View File
@@ -1,4 +1,4 @@
//! Phase 15: link-health diagnostics + TOC/links/orphans queries. //! Link-health diagnostics + TOC/links/orphans queries.
use std::path::PathBuf; use std::path::PathBuf;
@@ -630,7 +630,7 @@ fn anchor_matches_unicode_heading() {
// ===== COMMANDS completeness ===== // ===== COMMANDS completeness =====
#[test] #[test]
fn commands_list_includes_phase15_entries() { fn commands_list_includes_link_health_entries() {
let names: Vec<&str> = nuwiki_lsp::commands::COMMANDS.to_vec(); let names: Vec<&str> = nuwiki_lsp::commands::COMMANDS.to_vec();
for name in [ for name in [
"nuwiki.toc.generate", "nuwiki.toc.generate",
+5 -5
View File
@@ -1,8 +1,8 @@
//! Phase 18: multi-wiki — interwiki resolution + picker commands. //! Multi-wiki — interwiki resolution + picker commands.
//! //!
//! The cross-wiki resolver methods on `Backend` are exercised indirectly //! The cross-wiki resolver methods on `Backend` are exercised indirectly
//! through `tower_lsp::LspService`'s test harness via a synthetic //! through `tower_lsp::LspService`'s test harness via a synthetic
//! `Backend` (the same pattern Phase 13 used). To keep this test suite //! `Backend`. To keep this test suite
//! lightweight we drive the *pure* resolution primitives — the parser //! lightweight we drive the *pure* resolution primitives — the parser
//! producing `LinkKind::Interwiki` targets, then the `WorkspaceIndex` //! producing `LinkKind::Interwiki` targets, then the `WorkspaceIndex`
//! lookup-by-name — and verify the multi-wiki config shape end-to-end. //! lookup-by-name — and verify the multi-wiki config shape end-to-end.
@@ -80,7 +80,7 @@ fn multi_wiki_config_loads_two_entries() {
#[test] #[test]
fn v1_0_single_root_still_works_alongside_multi_wiki_shape() { fn v1_0_single_root_still_works_alongside_multi_wiki_shape() {
// Single-wiki shorthand is the v1.0 form; ensure it still desugars. // Single-wiki shorthand is the legacy form; ensure it still desugars.
let cfg = config_from_json(serde_json::json!({ let cfg = config_from_json(serde_json::json!({
"wiki_root": "/tmp/legacy", "wiki_root": "/tmp/legacy",
})); }));
@@ -135,7 +135,7 @@ fn nested_root_picks_longest_prefix_match() {
// ===== Cross-wiki resolution (uses WorkspaceIndex per wiki) ===== // ===== Cross-wiki resolution (uses WorkspaceIndex per wiki) =====
/// Verify that the cross-wiki resolution semantics match the SPEC: /// Verify the cross-wiki resolution semantics:
/// `[[wiki1:Page]]` looks up `Page` in the *second* wiki's index, not /// `[[wiki1:Page]]` looks up `Page` in the *second* wiki's index, not
/// the source wiki's. The pure-data check here mirrors what /// the source wiki's. The pure-data check here mirrors what
/// `Backend::resolve_target_uri` does. /// `Backend::resolve_target_uri` does.
@@ -185,7 +185,7 @@ fn interwiki_resolution_falls_back_when_wiki_missing() {
// ===== Wiki commands: COMMANDS list completeness ===== // ===== Wiki commands: COMMANDS list completeness =====
#[test] #[test]
fn commands_list_includes_phase18_entries() { fn commands_list_includes_wiki_entries() {
let names: Vec<&str> = nuwiki_lsp::commands::COMMANDS.to_vec(); let names: Vec<&str> = nuwiki_lsp::commands::COMMANDS.to_vec();
for name in [ for name in [
"nuwiki.wiki.listAll", "nuwiki.wiki.listAll",
+2 -2
View File
@@ -1,7 +1,7 @@
//! Tests for the Phase 8 navigation pieces: WorkspaceIndex queries, //! Tests for the navigation pieces: WorkspaceIndex queries,
//! page-name derivation, anchor slugify, position lookup, and link //! page-name derivation, anchor slugify, position lookup, and link
//! resolution. The async LSP handlers are exercised through these //! resolution. The async LSP handlers are exercised through these
//! helpers; end-to-end JSON-RPC drive lives in a follow-up phase. //! helpers; end-to-end JSON-RPC drive lives elsewhere.
use std::path::PathBuf; use std::path::PathBuf;
+249
View File
@@ -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.
+339
View File
@@ -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 16, `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 16, 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.
+276
View File
@@ -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"
}
+324
View File
@@ -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>
+9 -76
View File
@@ -10,11 +10,12 @@
# $XDG_CACHE_HOME/nuwiki-dev/wiki # $XDG_CACHE_HOME/nuwiki-dev/wiki
# * calls `require('nuwiki').setup({...})` # * calls `require('nuwiki').setup({...})`
# #
# Usage: ./start-nvim.sh [extra args...] # Usage: ./development/start-nvim.sh [extra args...]
# ./start-nvim.sh # open the scratch wiki's index # ./development/start-nvim.sh # open the scratch wiki's index
# ./start-nvim.sh path/to/note.wiki # open a specific file # ./development/start-nvim.sh path/to/note.wiki # open a specific file
# NUWIKI_DEV_WIKI=/tmp/foo ./start-nvim.sh # NUWIKI_DEV_WIKI=/tmp/foo ./development/start-nvim.sh
# NUWIKI_DEV_SKIP_BUILD=1 ./start-nvim.sh # reuse the cached binary # 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 # The release binary is rebuilt on every launch so source changes
# always reach the running LSP. `cargo build --release` is incremental, # always reach the running LSP. `cargo build --release` is incremental,
@@ -27,82 +28,14 @@
set -euo pipefail set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # Shared paths + helpers (log, ensure_binary, write_sample_wiki, seed_wiki).
DEV_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/nuwiki-dev" source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/_common.sh"
WIKI_DIR="${NUWIKI_DEV_WIKI:-$DEV_DIR/wiki}"
INIT_FILE="$DEV_DIR/init.lua" INIT_FILE="$DEV_DIR/init.lua"
STATE_DIR="$DEV_DIR/nvim-state" STATE_DIR="$DEV_DIR/nvim-state"
DATA_DIR="$DEV_DIR/nvim-data" DATA_DIR="$DEV_DIR/nvim-data"
CONFIG_DIR="$DEV_DIR/nvim-config" CONFIG_DIR="$DEV_DIR/nvim-config"
log() { printf '\033[1;34m[nuwiki-dev]\033[0m %s\n' "$*"; }
ensure_binary() {
local bin="$REPO_ROOT/bin/nuwiki-ls"
local built="$REPO_ROOT/target/release/nuwiki-ls"
if [[ "${NUWIKI_DEV_SKIP_BUILD:-0}" == "1" ]]; then
if [[ ! -x "$bin" ]]; then
log "NUWIKI_DEV_SKIP_BUILD=1 but $bin is missing — building anyway"
else
log "NUWIKI_DEV_SKIP_BUILD=1 — using existing $bin"
return
fi
fi
log "building nuwiki-ls (release)…"
cargo build --release -p nuwiki-ls --manifest-path "$REPO_ROOT/Cargo.toml"
mkdir -p "$REPO_ROOT/bin"
ln -sf "$built" "$bin"
log "binary ready: $bin (built $(date -r "$built" '+%Y-%m-%d %H:%M:%S'))"
}
seed_wiki() {
mkdir -p "$WIKI_DIR" "$WIKI_DIR/diary"
if [[ ! -f "$WIKI_DIR/index.wiki" ]]; then
cat > "$WIKI_DIR/index.wiki" <<'EOF'
= Welcome to nuwiki =
This is a scratch wiki created by start-nvim.sh. Edit, run commands,
or jump to the linked pages to test the plugin.
== Smoke test ==
- [[Notes]]
- [[diary/]]
- [ ] Toggle me with <C-Space>
- [ ] Or with :VimwikiToggleListItem
== Commands to try ==
- :VimwikiTOC
- :VimwikiGenerateLinks
- :VimwikiCheckLinks
- :VimwikiMakeDiaryNote
- :Vimwiki2HTMLBrowse
== Tags ==
:smoke-test:sandbox:
== Links ==
* [[Notes]]
* [[Notes#section-one]]
* https://example.com
EOF
fi
if [[ ! -f "$WIKI_DIR/Notes.wiki" ]]; then
cat > "$WIKI_DIR/Notes.wiki" <<'EOF'
= Notes =
A second page so [[index]] has somewhere to point.
== Section one ==
Anchor target for :checkhealth-style smoke tests.
EOF
fi
}
write_init() { write_init() {
mkdir -p "$DEV_DIR" mkdir -p "$DEV_DIR"
cat > "$INIT_FILE" <<EOF cat > "$INIT_FILE" <<EOF
+10 -75
View File
@@ -7,13 +7,13 @@
# the dev cache on first run so the LSP integration is exercisable # the dev cache on first run so the LSP integration is exercisable
# without polluting your real Vim install. # without polluting your real Vim install.
# #
# Usage: ./start-vim.sh [extra args...] # Usage: ./development/start-vim.sh [extra args...]
# ./start-vim.sh # open the scratch wiki's index # ./development/start-vim.sh # open the scratch wiki's index
# ./start-vim.sh path/to/note.wiki # open a specific file # ./development/start-vim.sh path/to/note.wiki # open a specific file
# NUWIKI_DEV_WIKI=/tmp/foo ./start-vim.sh # NUWIKI_DEV_WIKI=/tmp/foo ./development/start-vim.sh
# NUWIKI_DEV_NO_LSP=1 ./start-vim.sh # skip vim-lsp setup # NUWIKI_DEV_NO_LSP=1 ./development/start-vim.sh # skip vim-lsp setup
# NUWIKI_DEV_NO_SEED=1 ./start-vim.sh # use WIKI_DIR as-is (no scratch seeding) # NUWIKI_DEV_NO_SEED=1 ./development/start-vim.sh # use WIKI_DIR as-is (no scratch seeding)
# NUWIKI_DEV_SKIP_BUILD=1 ./start-vim.sh # reuse the cached binary # NUWIKI_DEV_SKIP_BUILD=1 ./development/start-vim.sh # reuse the cached binary
# #
# The release binary is rebuilt on every launch so source changes # The release binary is rebuilt on every launch so source changes
# always reach the running LSP. `cargo build --release` is incremental, # always reach the running LSP. `cargo build --release` is incremental,
@@ -25,34 +25,14 @@
set -euo pipefail set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # Shared paths + helpers (log, ensure_binary, write_sample_wiki, seed_wiki).
DEV_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/nuwiki-dev" source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/_common.sh"
WIKI_DIR="${NUWIKI_DEV_WIKI:-$DEV_DIR/wiki}"
VIMRC="$DEV_DIR/vimrc" VIMRC="$DEV_DIR/vimrc"
VIM_STATE="$DEV_DIR/vim-state" VIM_STATE="$DEV_DIR/vim-state"
VIM_LSP_DIR="$DEV_DIR/vim-lsp" VIM_LSP_DIR="$DEV_DIR/vim-lsp"
ASYNC_DIR="$DEV_DIR/async.vim" ASYNC_DIR="$DEV_DIR/async.vim"
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'))"
}
clone_if_missing() { clone_if_missing() {
local repo_url="$1" local repo_url="$1"
local dest="$2" local dest="$2"
@@ -77,51 +57,6 @@ ensure_vim_lsp() {
clone_if_missing https://github.com/prabirshrestha/async.vim.git "$ASYNC_DIR" || true clone_if_missing https://github.com/prabirshrestha/async.vim.git "$ASYNC_DIR" || true
} }
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"
if [[ ! -f "$WIKI_DIR/index.wiki" ]]; then
cat > "$WIKI_DIR/index.wiki" <<'EOF'
= Welcome to nuwiki =
This is a scratch wiki created by start-vim.sh. Edit, run commands,
or jump to the linked pages to test the plugin.
== Smoke test ==
- [[Notes]]
- [[diary/]]
- [ ] Toggle me with :VimwikiToggleListItem
- [ ] Inspect the LSP via :LspStatus
== Commands to try ==
- :VimwikiTOC
- :VimwikiGenerateLinks
- :VimwikiCheckLinks
- :VimwikiMakeDiaryNote
== Tags ==
:smoke-test:sandbox:
EOF
fi
if [[ ! -f "$WIKI_DIR/Notes.wiki" ]]; then
cat > "$WIKI_DIR/Notes.wiki" <<'EOF'
= Notes =
A second page so [[index]] has somewhere to point.
== Section one ==
Anchor target for cross-page link follows.
EOF
fi
}
write_vimrc() { write_vimrc() {
mkdir -p "$DEV_DIR" mkdir -p "$DEV_DIR"
cat > "$VIMRC" <<EOF cat > "$VIMRC" <<EOF
@@ -1,8 +1,8 @@
" scripts/syntax-diag.vim — dump highlighting state to /tmp/nuwiki-syndiag.log " development/syntax-diag.vim — dump highlighting state to /tmp/nuwiki-syndiag.log
" "
" Usage: open a .wiki file with VARIED markup (heading, *bold*, _italic_, " Usage: open a .wiki file with VARIED markup (heading, *bold*, _italic_,
" `code`), then: " `code`), then:
" :source scripts/syntax-diag.vim " :source development/syntax-diag.vim
" Then paste /tmp/nuwiki-syndiag.log back. " Then paste /tmp/nuwiki-syndiag.log back.
redir! > /tmp/nuwiki-syndiag.log redir! > /tmp/nuwiki-syndiag.log
@@ -1,16 +1,16 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# #
# scripts/test-keymaps-vim.sh — Vim counterpart of test-keymaps.sh. # development/tests/test-keymaps-vim.sh — Vim counterpart of test-keymaps.sh.
# #
# Pure-VimL portion only. The LSP-roundtrip keymaps (`<C-Space>`, # Pure-VimL portion only. The LSP-roundtrip keymaps (`<C-Space>`,
# `=`, `-`, …) talk to the server via vim-lsp's timer-driven async # `=`, `-`, …) talk to the server via vim-lsp's timer-driven async
# layer, which doesn't fire reliably inside `vim -e -s`. Those have # layer, which doesn't fire reliably inside `vim -e -s`. Those have
# Neovim coverage in scripts/test-keymaps.lua, exercising the same # Neovim coverage in development/tests/test-keymaps.lua, exercising the same
# Lua command layer + server binary. # Lua command layer + server binary.
set -euo pipefail set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
TMP="$(mktemp -d -t nuwiki-keymap-vim-test-XXXXXX)" TMP="$(mktemp -d -t nuwiki-keymap-vim-test-XXXXXX)"
trap 'rm -rf "$TMP"' EXIT trap 'rm -rf "$TMP"' EXIT
@@ -33,6 +33,9 @@ VIMRC="$TMP/vimrc"
cat > "$VIMRC" <<EOF cat > "$VIMRC" <<EOF
set nocompatible set nocompatible
let &runtimepath = '$REPO_ROOT' . ',' . &runtimepath 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 filetype plugin indent on
syntax enable syntax enable
" ftdetect/nuwiki.vim already maps *.wiki → vimwiki via \`set filetype\`. " ftdetect/nuwiki.vim already maps *.wiki → vimwiki via \`set filetype\`.
@@ -44,7 +47,7 @@ log "running harness…"
# `</dev/null` so Vim's ex-mode doesn't hang waiting on stdin; # `</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. # `timeout` so a misbehaving script can't stall CI for 30 minutes.
NUWIKI_KEYMAP_RESULTS="$RESULTS" \ NUWIKI_KEYMAP_RESULTS="$RESULTS" \
timeout 30 vim -e -s -u "$VIMRC" -c "edit $SEED" -c "source $REPO_ROOT/scripts/test-keymaps-vim.vim" \ 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 </dev/null >"$TMP/vim.log" 2>&1 || true
if [[ ! -f "$RESULTS" ]]; then if [[ ! -f "$RESULTS" ]]; then
@@ -1,4 +1,4 @@
" scripts/test-keymaps-vim.vim — driven by scripts/test-keymaps-vim.sh. " development/tests/test-keymaps-vim.vim — driven by development/tests/test-keymaps-vim.sh.
" "
" Pure-VimL keymap regression suite. Plain Vim's LSP path " Pure-VimL keymap regression suite. Plain Vim's LSP path
" (vim-lsp + async.vim) talks to the server via timers, which won't " (vim-lsp + async.vim) talks to the server via timers, which won't
@@ -6,7 +6,7 @@
" are restricted to the bindings whose RHS is implemented entirely in " are restricted to the bindings whose RHS is implemented entirely in
" VimL (header/link nav, `o`/`O` bullet continuation, the wrap step " VimL (header/link nav, `o`/`O` bullet continuation, the wrap step
" of `<CR>`). LSP-roundtrip bindings (`<C-Space>`, `=`, `-`, …) get " of `<CR>`). LSP-roundtrip bindings (`<C-Space>`, `=`, `-`, …) get
" their coverage from the Neovim harness in scripts/test-keymaps.lua " their coverage from the Neovim harness in development/tests/test-keymaps.lua
" — same Lua codepath, same server. " — same Lua codepath, same server.
let s:results = [] let s:results = []
@@ -95,6 +95,107 @@ else
call s:record(0, 'cmd.NuwikiIndex_defined', ':NuwikiIndex not registered') call s:record(0, 'cmd.NuwikiIndex_defined', ':NuwikiIndex not registered')
endif 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) ===== " ===== Header nav (pure VimL) =====
call s:run('headers.]]_jumps_to_next', { call s:run('headers.]]_jumps_to_next', {
@@ -121,6 +222,12 @@ call s:run('headers.]=_jumps_to_next_sibling', {
\ 'keys': ']=', \ 'keys': ']=',
\ 'expect_cursor_line': 4, \ '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) ===== " ===== Link nav (pure VimL) =====
@@ -1,4 +1,4 @@
-- scripts/test-keymaps.lua — driven by scripts/test-keymaps.sh. -- development/tests/test-keymaps.lua — driven by development/tests/test-keymaps.sh.
-- --
-- Headless Neovim harness for the buffer-local keymaps registered by -- Headless Neovim harness for the buffer-local keymaps registered by
-- `ftplugin/vimwiki.vim` + `lua/nuwiki/keymaps.lua`. Each case sets up -- `ftplugin/vimwiki.vim` + `lua/nuwiki/keymaps.lua`. Each case sets up
@@ -123,6 +123,105 @@ vim.defer_fn(function()
end end
record(true, 'lsp.attached', 'client=' .. client.name) 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 ===== -- ===== Lists =====
run('list.toggle_via_C-Space', { run('list.toggle_via_C-Space', {
@@ -167,6 +266,84 @@ vim.defer_fn(function()
expect_line = '- [ ] done', expect_line = '- [ ] done',
expect_at = 1, 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) ===== -- ===== Next task (gnt) =====
@@ -247,7 +424,7 @@ vim.defer_fn(function()
wait_ms = 200, wait_ms = 200,
}) })
-- ===== Link helpers (§13.1 Cluster C) ===== -- ===== Link helpers (Cluster C) =====
run('links.normalize_via_+', { run('links.normalize_via_+', {
lines = { 'MyPage rest' }, lines = { 'MyPage rest' },
@@ -1,6 +1,6 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# #
# scripts/test-keymaps.sh — drive scripts/test-keymaps.lua under # development/tests/test-keymaps.sh — drive development/tests/test-keymaps.lua under
# headless Neovim and report pass/fail per buffer-local keymap. # headless Neovim and report pass/fail per buffer-local keymap.
# #
# The Lua harness needs a real LSP attachment (most keymaps dispatch # The Lua harness needs a real LSP attachment (most keymaps dispatch
@@ -14,7 +14,7 @@
set -euo pipefail set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
TMP="$(mktemp -d -t nuwiki-keymap-test-XXXXXX)" TMP="$(mktemp -d -t nuwiki-keymap-test-XXXXXX)"
trap 'rm -rf "$TMP"' EXIT trap 'rm -rf "$TMP"' EXIT
@@ -38,7 +38,9 @@ cat > "$INIT" <<EOF
-- Keep the harness self-contained; no plugin manager required. -- Keep the harness self-contained; no plugin manager required.
vim.opt.runtimepath:prepend('$REPO_ROOT') vim.opt.runtimepath:prepend('$REPO_ROOT')
vim.g.nuwiki_binary_path = '$BIN' vim.g.nuwiki_binary_path = '$BIN'
require('nuwiki').setup({ wiki_root = '$TMP/wiki' }) -- 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 EOF
RESULTS="$TMP/results.txt" RESULTS="$TMP/results.txt"
@@ -51,7 +53,7 @@ log "running harness…"
# stalling CI runners indefinitely. # stalling CI runners indefinitely.
NUWIKI_KEYMAP_RESULTS="$RESULTS" \ NUWIKI_KEYMAP_RESULTS="$RESULTS" \
timeout 60 nvim --clean -u "$INIT" --headless "$TMP/wiki/index.wiki" \ timeout 60 nvim --clean -u "$INIT" --headless "$TMP/wiki/index.wiki" \
-c "luafile $REPO_ROOT/scripts/test-keymaps.lua" \ -c "luafile $REPO_ROOT/development/tests/test-keymaps.lua" \
>"$TMP/nvim.log" 2>&1 || true >"$TMP/nvim.log" 2>&1 || true
if [[ ! -f "$RESULTS" ]]; then if [[ ! -f "$RESULTS" ]]; then
+70 -65
View File
@@ -228,63 +228,68 @@ Vim-specific globals ~
Override the auto-discovered server binary path. If set and the Override the auto-discovered server binary path. If set and the
file is readable, it overrides the `{plugin}/bin/nuwiki-ls` default. file is readable, it overrides the `{plugin}/bin/nuwiki-ls` default.
==============================================================================
============================================================================== ==============================================================================
5. COMMANDS *nuwiki-commands* 5. COMMANDS *nuwiki-commands*
Every command listed below is registered buffer-local on `.wiki` files All commands are available in both forms:
under both names: the `:Vimwiki*` form (for migration from vimwiki) and - Canonical form: :Nuwiki*
the canonical `:Nuwiki*` alias. Only the `:Vimwiki*` form is shown. - Vimwiki-compatible form: :Vimwiki*
Every command listed below is registered buffer-logic on `.wiki` files.
Only the `:Nuwiki*` form is shown in this document for brevity.
Wiki / navigation ~ Wiki / navigation ~
*:VimwikiIndex* *:NuwikiIndex*
:VimwikiIndex [{count}] :NuwikiIndex [{count}]
Open wiki N's index page (default: 1). Open wiki N's index page (default: 1).
*:VimwikiTabIndex* *:NuwikiTabIndex*
:VimwikiTabIndex [{count}] :NuwikiTabIndex [{count}]
Same, in a new tab. Same, in a new tab.
*:VimwikiUISelect* *:NuwikiUISelect*
:VimwikiUISelect :NuwikiUISelect
Pick a wiki from a list (multi-wiki only). Uses |vim.ui.select| on Pick a wiki from a list (multi-wiki only). Uses |vim.ui.select| on
Neovim, |inputlist()| on Vim. Neovim, |inputlist()| on Vim.
*:VimwikiGoto* *:NuwikiGoto*
:VimwikiGoto {page} :NuwikiGoto {page}
Open `{page}.wiki` by name. Open `{page}.wiki` by name.
*:VimwikiFollowLink* *:NuwikiFollowLink*
:VimwikiFollowLink :NuwikiFollowLink
Follow the link under the cursor. Follow the link under the cursor.
*:VimwikiBacklinks* *:NuwikiBacklinks*
:VimwikiBacklinks :NuwikiBacklinks
Show all references to the current page. Show all references to the current page.
*:VimwikiNextLink* *:NuwikiNextLink*
:VimwikiNextLink :NuwikiNextLink
*:VimwikiPrevLink* *:NuwikiPrevLink*
:VimwikiPrevLink :NuwikiPrevLink
Jump to the next / previous wikilink on the page. Jump to the next / previous wikilink on the page.
*:VimwikiBaddLink* *:NuwikiBaddLink*
:VimwikiBaddLink :NuwikiBaddLink
Add the target of the link under the cursor to the buffer list. Add the target of the link under the cursor to the buffer list.
*:VimwikiRenameFile* *:NuwikiRenameFile*
:VimwikiRenameFile :NuwikiRenameFile
Prompt for a new name; rename the current page and rewrite every Prompt for a new name; rename the current page and rewrite every
inbound link. inbound link.
*:VimwikiDeleteFile* *:NuwikiDeleteFile*
:VimwikiDeleteFile :NuwikiDeleteFile
Delete the current page and its on-disk file. Delete the current page and its on-disk file.
Diary ~ Diary ~
*:VimwikiMakeDiaryNote* *:NuwikiMakeDiaryNote*
:VimwikiMakeDiaryNote :NuwikiMakeDiaryNote
Open today's diary entry. With `diary_frequency = 'weekly'` (or Open today's diary entry. With `diary_frequency = 'weekly'` (or
`'monthly'` / `'yearly'`) this addresses this week's / this month's `'monthly'` / `'yearly'`) this addresses this week's / this month's
/ this year's entry instead. The file stems follow: / this year's entry instead. The file stems follow:
@@ -294,74 +299,74 @@ Diary ~
monthly YYYY-MM 2026-05.wiki monthly YYYY-MM 2026-05.wiki
yearly YYYY 2026.wiki yearly YYYY 2026.wiki
*:VimwikiMakeYesterdayDiaryNote* *:NuwikiMakeYesterdayDiaryNote*
:VimwikiMakeYesterdayDiaryNote :NuwikiMakeYesterdayDiaryNote
*:VimwikiMakeTomorrowDiaryNote* *:NuwikiMakeTomorrowDiaryNote*
:VimwikiMakeTomorrowDiaryNote :NuwikiMakeTomorrowDiaryNote
Step the diary back / forward by one period at the configured Step the diary back / forward by one period at the configured
cadence. cadence.
*:VimwikiDiaryIndex* *:NuwikiDiaryIndex*
:VimwikiDiaryIndex :NuwikiDiaryIndex
Open the diary index page. Open the diary index page.
*:VimwikiDiaryGenerateLinks* *:NuwikiDiaryGenerateLinks*
:VimwikiDiaryGenerateLinks :NuwikiDiaryGenerateLinks
Rebuild the diary index page from the entries currently on disk. Rebuild the diary index page from the entries currently on disk.
*:VimwikiDiaryNextDay* *:NuwikiDiaryNextDay*
:VimwikiDiaryNextDay :NuwikiDiaryNextDay
*:VimwikiDiaryPrevDay* *:NuwikiDiaryPrevDay*
:VimwikiDiaryPrevDay :NuwikiDiaryPrevDay
Walk to the next / previous indexed diary entry at the same cadence Walk to the next / previous indexed diary entry at the same cadence
as the current one. as the current one.
Page generation ~ Page generation ~
*:VimwikiTOC* *:NuwikiTOC*
:VimwikiTOC :NuwikiTOC
Generate or refresh the table of contents on the current page. Generate or refresh the table of contents on the current page.
*:VimwikiGenerateLinks* *:NuwikiGenerateLinks*
:VimwikiGenerateLinks :NuwikiGenerateLinks
Insert a flat list of every page in the wiki under the cursor. Insert a flat list of every page in the wiki under the cursor.
*:VimwikiCheckLinks* *:NuwikiCheckLinks*
:VimwikiCheckLinks :NuwikiCheckLinks
Send every broken link in the workspace to the |quickfix| list. Send every broken link in the workspace to the |quickfix| list.
Tags ~ Tags ~
*:VimwikiSearchTags* *:NuwikiSearchTags*
:VimwikiSearchTags {tag} :NuwikiSearchTags {tag}
Quickfix listing every `:tag:` occurrence. Quickfix listing every `:tag:` occurrence.
*:VimwikiGenerateTagLinks* *:NuwikiGenerateTagLinks*
:VimwikiGenerateTagLinks [tag] :NuwikiGenerateTagLinks [tag]
Insert a section linking every page that has `<tag>`. With no Insert a section linking every page that has `<tag>`. With no
argument, generates a section per tag found in the workspace. argument, generates a section per tag found in the workspace.
*:VimwikiRebuildTags* *:NuwikiRebuildTags*
:VimwikiRebuildTags :NuwikiRebuildTags
Force a full workspace re-index. Force a full workspace re-index.
HTML export ~ HTML export ~
*:Vimwiki2HTML* *:Nuwiki2HTML*
:Vimwiki2HTML :Nuwiki2HTML
Render the current page to HTML. Render the current page to HTML.
*:Vimwiki2HTMLBrowse* *:Nuwiki2HTMLBrowse*
:Vimwiki2HTMLBrowse :Nuwiki2HTMLBrowse
Render the current page and open it in the default browser. Render the current page and open it in the default browser.
*:VimwikiAll2HTML* *:NuwikiAll2HTML*
:VimwikiAll2HTML[!] :NuwikiAll2HTML[!]
Export every page in the wiki. `!` forces a full rebuild; otherwise Export every page in the wiki. `!` forces a full rebuild; otherwise
only out-of-date pages are re-rendered. only out-of-date pages are re-rendered.
*:VimwikiRss* *:NuwikiRss*
:VimwikiRss :NuwikiRss
Write `rss.xml` summarising recent diary entries. Write `rss.xml` summarising recent diary entries.
Other ~ Other ~
@@ -522,7 +527,7 @@ The `diary_frequency` per-wiki option controls the cadence:
monthly YYYY-MM e.g. `2026-05.wiki` monthly YYYY-MM e.g. `2026-05.wiki`
yearly YYYY e.g. `2026.wiki` yearly YYYY e.g. `2026.wiki`
`:VimwikiMakeDiaryNote` opens the entry for the current period at the `:NuwikiMakeDiaryNote` opens the entry for the current period at the
configured cadence; the yesterday / tomorrow commands step back and configured cadence; the yesterday / tomorrow commands step back and
forward by one period. `<C-Down>` / `<C-Up>` walk between indexed forward by one period. `<C-Down>` / `<C-Up>` walk between indexed
entries of the same flavour as the one under the cursor — so on a entries of the same flavour as the one under the cursor — so on a
@@ -530,7 +535,7 @@ weekly page, navigation stays weekly.
Index ~ Index ~
`:VimwikiDiaryGenerateLinks` rebuilds the diary index page (`diary.wiki` `:NuwikiDiaryGenerateLinks` rebuilds the diary index page (`diary.wiki`
by default) with a chronological list of every entry on disk, grouped by default) with a chronological list of every entry on disk, grouped
by year / month. The page name is set by `diary_index`; the heading is by year / month. The page name is set by `diary_index`; the heading is
set by `diary_header`. set by `diary_header`.
@@ -564,8 +569,8 @@ column moves, cell navigation, new-row insertion).
============================================================================== ==============================================================================
11. HTML EXPORT *nuwiki-html* 11. HTML EXPORT *nuwiki-html*
`:Vimwiki2HTML` renders the current page; `:Vimwiki2HTMLBrowse` opens `:Nuwiki2HTML` renders the current page; `:Nuwiki2HTMLBrowse` opens
the result in the default browser; `:VimwikiAll2HTML` exports every the result in the default browser; `:NuwikiAll2HTML` exports every
page (incremental by default; `!` forces a full rebuild). page (incremental by default; `!` forces a full rebuild).
Per-wiki knobs: Per-wiki knobs:
@@ -579,7 +584,7 @@ Per-wiki knobs:
`exclude_files` Glob patterns excluded from export. `exclude_files` Glob patterns excluded from export.
Templates may include `{title}`, `{rootpath}`, `{content}`, `{css}`, Templates may include `{title}`, `{rootpath}`, `{content}`, `{css}`,
`{date}` placeholders. RSS for the diary is written by `:VimwikiRss`. `{date}` placeholders. RSS for the diary is written by `:NuwikiRss`.
============================================================================== ==============================================================================
12. FOLDING *nuwiki-folding* 12. FOLDING *nuwiki-folding*
@@ -631,7 +636,7 @@ match vimwiki. To migrate:
Existing pages, diary entries, tags, and templates work without Existing pages, diary entries, tags, and templates work without
modification. The first workspace scan after launch may show an empty modification. The first workspace scan after launch may show an empty
`:VimwikiCheckLinks` result until the background index completes; `:NuwikiCheckLinks` result until the background index completes;
subsequent runs are instant. subsequent runs are instant.
vim:tw=78:ts=8:ft=help:norl: vim:tw=78:ts=8:ft=help:norl:
+38 -9
View File
@@ -1,9 +1,9 @@
" ftplugin/nuwiki.vim — per-buffer settings + Phase 19 command/keymap glue. " ftplugin/nuwiki.vim — per-buffer settings + command/keymap glue.
" "
" Wires up: " Wires up:
" - basic edit-time options (commentstring, suffixesadd, …) " - basic edit-time options (commentstring, suffixesadd, …)
" - every `:Vimwiki*` / `:Nuwiki*` command from SPEC §12.10 that has a " - every `:Vimwiki*` / `:Nuwiki*` command that has a
" server-side counterpart (deferred §13.1 commands stub out with a " server-side counterpart (deferred commands stub out with a
" "not yet implemented" notification) " "not yet implemented" notification)
" - on Neovim: keymaps + text objects + folding via " - on Neovim: keymaps + text objects + folding via
" `nuwiki.ftplugin.attach()` " `nuwiki.ftplugin.attach()`
@@ -78,7 +78,7 @@ if !has('nvim')
command! -buffer -nargs=? VimwikiSearchTags call nuwiki#commands#tags_search(<q-args>) command! -buffer -nargs=? VimwikiSearchTags call nuwiki#commands#tags_search(<q-args>)
command! -buffer -nargs=? VimwikiGenerateTagLinks call nuwiki#commands#tags_generate_links(<q-args>) command! -buffer -nargs=? VimwikiGenerateTagLinks call nuwiki#commands#tags_generate_links(<q-args>)
" §13.1 deferred stubs. " Deferred stubs.
command! -buffer -nargs=1 VimwikiTable call nuwiki#commands#table_insert() command! -buffer -nargs=1 VimwikiTable call nuwiki#commands#table_insert()
command! -buffer VimwikiTableMoveColumnLeft call nuwiki#commands#table_move_left() command! -buffer VimwikiTableMoveColumnLeft call nuwiki#commands#table_move_left()
command! -buffer VimwikiTableMoveColumnRight call nuwiki#commands#table_move_right() command! -buffer VimwikiTableMoveColumnRight call nuwiki#commands#table_move_right()
@@ -115,6 +115,21 @@ if !has('nvim')
command! -buffer -nargs=? NuwikiGenerateTagLinks call nuwiki#commands#tags_generate_links(<q-args>) command! -buffer -nargs=? NuwikiGenerateTagLinks call nuwiki#commands#tags_generate_links(<q-args>)
command! -buffer -nargs=1 NuwikiGoto call nuwiki#commands#wiki_goto_page(<q-args>) command! -buffer -nargs=1 NuwikiGoto call nuwiki#commands#wiki_goto_page(<q-args>)
" Canonical :Nuwiki* names that mirror the documented surface.
" The older :Nuwiki* spellings above stay defined for back-compat.
command! -buffer NuwikiNextLink call nuwiki#commands#link_next()
command! -buffer NuwikiPrevLink call nuwiki#commands#link_prev()
command! -buffer NuwikiBaddLink call nuwiki#commands#badd_link()
command! -buffer NuwikiMakeDiaryNote call nuwiki#commands#diary_today()
command! -buffer NuwikiMakeYesterdayDiaryNote call nuwiki#commands#diary_yesterday()
command! -buffer NuwikiMakeTomorrowDiaryNote call nuwiki#commands#diary_tomorrow()
command! -buffer NuwikiDiaryGenerateLinks call nuwiki#commands#diary_generate_index()
command! -buffer NuwikiDiaryNextDay call nuwiki#commands#diary_next()
command! -buffer NuwikiDiaryPrevDay call nuwiki#commands#diary_prev()
command! -buffer Nuwiki2HTML call nuwiki#commands#export_current()
command! -buffer Nuwiki2HTMLBrowse call nuwiki#commands#export_browse()
command! -buffer -bang NuwikiAll2HTML if <bang>0 | call nuwiki#commands#export_all_force() | else | call nuwiki#commands#export_all() | endif
" ===== Default key mappings (parity with upstream vimwiki) ===== " ===== Default key mappings (parity with upstream vimwiki) =====
" "
" Users can opt out by setting `g:nuwiki_no_default_mappings = 1` " Users can opt out by setting `g:nuwiki_no_default_mappings = 1`
@@ -162,8 +177,8 @@ if !has('nvim')
nnoremap <silent><buffer> gnt :call nuwiki#commands#next_task()<CR> nnoremap <silent><buffer> gnt :call nuwiki#commands#next_task()<CR>
nnoremap <silent><buffer> gln :call nuwiki#commands#cycle_list_item()<CR> nnoremap <silent><buffer> gln :call nuwiki#commands#cycle_list_item()<CR>
xnoremap <silent><buffer> gln :<C-u>call nuwiki#commands#cycle_list_item()<CR> xnoremap <silent><buffer> gln :<C-u>call nuwiki#commands#cycle_list_item()<CR>
nnoremap <silent><buffer> glp :call nuwiki#commands#cycle_list_item()<CR> nnoremap <silent><buffer> glp :call nuwiki#commands#cycle_list_item_back()<CR>
xnoremap <silent><buffer> glp :<C-u>call nuwiki#commands#cycle_list_item()<CR> xnoremap <silent><buffer> glp :<C-u>call nuwiki#commands#cycle_list_item_back()<CR>
nnoremap <silent><buffer> glx :call nuwiki#commands#reject_list_item()<CR> nnoremap <silent><buffer> glx :call nuwiki#commands#reject_list_item()<CR>
xnoremap <silent><buffer> glx :<C-u>call nuwiki#commands#reject_list_item()<CR> xnoremap <silent><buffer> glx :<C-u>call nuwiki#commands#reject_list_item()<CR>
nnoremap <silent><buffer> glh :call nuwiki#commands#list_change_level(-1, 0)<CR> nnoremap <silent><buffer> glh :call nuwiki#commands#list_change_level(-1, 0)<CR>
@@ -173,7 +188,7 @@ if !has('nvim')
nnoremap <silent><buffer> glr :call nuwiki#commands#list_renumber()<CR> nnoremap <silent><buffer> glr :call nuwiki#commands#list_renumber()<CR>
nnoremap <silent><buffer> gLr :call nuwiki#commands#list_renumber_all()<CR> nnoremap <silent><buffer> gLr :call nuwiki#commands#list_renumber_all()<CR>
nnoremap <silent><buffer> gl<Space> :call nuwiki#commands#list_remove_done()<CR> nnoremap <silent><buffer> gl<Space> :call nuwiki#commands#list_remove_done()<CR>
nnoremap <silent><buffer> gL<Space> :call nuwiki#commands#list_remove_done()<CR> nnoremap <silent><buffer> gL<Space> :call nuwiki#commands#list_remove_done_all()<CR>
nnoremap <silent><buffer> o :call nuwiki#commands#open_below_with_bullet()<CR> nnoremap <silent><buffer> o :call nuwiki#commands#open_below_with_bullet()<CR>
nnoremap <silent><buffer> O :call nuwiki#commands#open_above_with_bullet()<CR> nnoremap <silent><buffer> O :call nuwiki#commands#open_above_with_bullet()<CR>
@@ -316,7 +331,7 @@ command! -buffer VimwikiRebuildTags lua require('nuwiki.commands').
command! -buffer -nargs=? VimwikiSearchTags lua require('nuwiki.commands').tags_search(<q-args>) command! -buffer -nargs=? VimwikiSearchTags lua require('nuwiki.commands').tags_search(<q-args>)
command! -buffer -nargs=? VimwikiGenerateTagLinks lua require('nuwiki.commands').tags_generate_links(<q-args>) command! -buffer -nargs=? VimwikiGenerateTagLinks lua require('nuwiki.commands').tags_generate_links(<q-args>)
" §13.1 deferred — stubs notify the user instead of erroring. " Deferred — stubs notify the user instead of erroring.
command! -buffer -nargs=1 VimwikiTable lua require('nuwiki.commands').table_insert() command! -buffer -nargs=1 VimwikiTable lua require('nuwiki.commands').table_insert()
command! -buffer VimwikiTableMoveColumnLeft lua require('nuwiki.commands').table_move_column_left() command! -buffer VimwikiTableMoveColumnLeft lua require('nuwiki.commands').table_move_column_left()
command! -buffer VimwikiTableMoveColumnRight lua require('nuwiki.commands').table_move_column_right() command! -buffer VimwikiTableMoveColumnRight lua require('nuwiki.commands').table_move_column_right()
@@ -355,5 +370,19 @@ command! -buffer -nargs=? NuwikiSearchTags lua require('nuwiki.commands'
command! -buffer -nargs=? NuwikiGenerateTagLinks lua require('nuwiki.commands').tags_generate_links(<q-args>) command! -buffer -nargs=? NuwikiGenerateTagLinks lua require('nuwiki.commands').tags_generate_links(<q-args>)
command! -buffer -nargs=1 NuwikiGoto lua require('nuwiki.commands').wiki_goto_page(<q-args>) command! -buffer -nargs=1 NuwikiGoto lua require('nuwiki.commands').wiki_goto_page(<q-args>)
" Phase 19 buffer attach: keymaps + text objects + folding. " Canonical :Nuwiki* names that mirror the documented surface.
" The older :Nuwiki* spellings above stay defined for back-compat.
command! -buffer NuwikiNextLink lua require('nuwiki.commands').link_next()
command! -buffer NuwikiPrevLink lua require('nuwiki.commands').link_prev()
command! -buffer NuwikiBaddLink lua require('nuwiki.commands').badd_link()
command! -buffer NuwikiMakeDiaryNote lua require('nuwiki.commands').diary_today()
command! -buffer NuwikiMakeYesterdayDiaryNote lua require('nuwiki.commands').diary_yesterday()
command! -buffer NuwikiMakeTomorrowDiaryNote lua require('nuwiki.commands').diary_tomorrow()
command! -buffer NuwikiDiaryNextDay lua require('nuwiki.commands').diary_next()
command! -buffer NuwikiDiaryPrevDay lua require('nuwiki.commands').diary_prev()
command! -buffer Nuwiki2HTML lua require('nuwiki.commands').export_current()
command! -buffer Nuwiki2HTMLBrowse lua require('nuwiki.commands').export_browse()
command! -buffer -bang NuwikiAll2HTML execute (<bang>0 ? "lua require('nuwiki.commands').export_all_force()" : "lua require('nuwiki.commands').export_all()")
" Buffer attach: keymaps + text objects + folding.
lua require('nuwiki.ftplugin').attach(0) lua require('nuwiki.ftplugin').attach(0)
+19 -6
View File
@@ -1,5 +1,5 @@
-- lua/nuwiki/commands.lua — Lua wrappers around every `nuwiki.*` -- lua/nuwiki/commands.lua — Lua wrappers around every `nuwiki.*`
-- `workspace/executeCommand` the server advertises. Phase 19 plumbing. -- `workspace/executeCommand` the server advertises.
-- --
-- Each public function is named after its `:Vimwiki*` / `:Nuwiki*` -- Each public function is named after its `:Vimwiki*` / `:Nuwiki*`
-- counterpart so `ftplugin/nuwiki.vim` can wire them up declaratively. -- counterpart so `ftplugin/nuwiki.vim` can wire them up declaratively.
@@ -323,6 +323,12 @@ end
M.toggle_list_item = _exec_pos('nuwiki.list.toggleCheckbox') M.toggle_list_item = _exec_pos('nuwiki.list.toggleCheckbox')
M.cycle_list_item = _exec_pos('nuwiki.list.cycleCheckbox') M.cycle_list_item = _exec_pos('nuwiki.list.cycleCheckbox')
-- `glp` cycles backward — same command, `reverse = true` in the payload.
function M.cycle_list_item_back()
local a = pos_args()
a[1].reverse = true
exec('nuwiki.list.cycleCheckbox', a)
end
M.reject_list_item = _exec_pos('nuwiki.list.rejectCheckbox') M.reject_list_item = _exec_pos('nuwiki.list.rejectCheckbox')
M.heading_add_level = _exec_pos('nuwiki.heading.addLevel') M.heading_add_level = _exec_pos('nuwiki.heading.addLevel')
M.heading_remove_level = _exec_pos('nuwiki.heading.removeLevel') M.heading_remove_level = _exec_pos('nuwiki.heading.removeLevel')
@@ -462,7 +468,7 @@ function M.rename_file()
vim.lsp.buf.rename() vim.lsp.buf.rename()
end end
-- ===== Deferred placeholders (§13.1) ===== -- ===== Deferred placeholders =====
-- --
-- These commands aren't implemented on the server yet. Stub them so the -- These commands aren't implemented on the server yet. Stub them so the
-- `:Vimwiki*` compat surface exists and users get a clear message -- `:Vimwiki*` compat surface exists and users get a clear message
@@ -471,15 +477,22 @@ end
local function _not_yet(name) local function _not_yet(name)
return function() return function()
vim.notify( vim.notify(
'nuwiki: ' .. name .. ' is not yet implemented — see SPEC §13.1', 'nuwiki: ' .. name .. ' is not yet implemented',
vim.log.levels.WARN vim.log.levels.WARN
) )
end end
end end
-- §13.1 Cluster A — list rewriters. -- Cluster A — list rewriters.
-- `gl<Space>` — remove done items from the current list only. Passing the
-- cursor position scopes the server to the contiguous list block under it.
function M.list_remove_done() function M.list_remove_done()
exec('nuwiki.list.removeDone', pos_args())
end
-- `gL<Space>` — remove done items across the whole buffer (no position).
function M.list_remove_done_all()
exec('nuwiki.list.removeDone', uri_args()) exec('nuwiki.list.removeDone', uri_args())
end end
@@ -878,7 +891,7 @@ function M.smart_shift_tab()
) )
end end
-- §13.1 Cluster B — table rewriters. -- Cluster B — table rewriters.
function M.table_insert(cols, rows) function M.table_insert(cols, rows)
cols = tonumber(cols) or 3 cols = tonumber(cols) or 3
@@ -961,7 +974,7 @@ function M.colorize(color)
vim.api.nvim_buf_set_lines(0, row - 1, row, false, { new_line }) vim.api.nvim_buf_set_lines(0, row - 1, row, false, { new_line })
end end
-- §13.1 Cluster C — link helpers. -- Cluster C — link helpers.
function M.paste_link() function M.paste_link()
exec('nuwiki.link.pasteWikilink', pos_args()) exec('nuwiki.link.pasteWikilink', pos_args())
+7 -8
View File
@@ -1,12 +1,11 @@
-- lua/nuwiki/config.lua — defaults and user-config merge. -- lua/nuwiki/config.lua — defaults and user-config merge.
-- --
-- Schema mirrors SPEC §7.5 + §12.11. Keep this aligned with -- Keep this aligned with `doc/nuwiki.txt`.
-- `doc/nuwiki.txt`.
local M = {} local M = {}
M.defaults = { M.defaults = {
-- v1.0 single-wiki shorthand. The server desugars these into a -- Single-wiki shorthand. The server desugars these into a
-- one-entry `wikis = {…}` list. Use either this or the multi-wiki -- one-entry `wikis = {…}` list. Use either this or the multi-wiki
-- form below; if both are set, `wikis` wins. -- form below; if both are set, `wikis` wins.
wiki_root = '~/vimwiki', wiki_root = '~/vimwiki',
@@ -14,12 +13,12 @@ M.defaults = {
syntax = 'vimwiki', syntax = 'vimwiki',
log_level = 'warn', log_level = 'warn',
-- v1.1 multi-wiki shape. Each entry honours every per-wiki key the -- Multi-wiki shape. Each entry honours every per-wiki key the
-- server understands. Leave nil to fall through to the shorthand -- server understands. Leave nil to fall through to the shorthand
-- above. -- above.
-- --
-- Per-wiki keys (all optional, server defaults documented in SPEC -- Per-wiki keys (all optional, server defaults documented in
-- §12.11): -- `doc/nuwiki.txt`):
-- name, root, file_extension, syntax, index -- name, root, file_extension, syntax, index
-- diary_rel_path, diary_index, diary_frequency, -- diary_rel_path, diary_index, diary_frequency,
-- diary_start_week_day, diary_caption_level, diary_sort, -- diary_start_week_day, diary_caption_level, diary_sort,
@@ -31,7 +30,7 @@ M.defaults = {
-- links_space_char, nested_syntaxes -- links_space_char, nested_syntaxes
wikis = nil, wikis = nil,
-- Phase 19: per-buffer glue. Each subgroup mirrors vimwiki's -- Per-buffer glue. Each subgroup mirrors vimwiki's
-- `g:vimwiki_key_mappings` shape and can be flipped off -- `g:vimwiki_key_mappings` shape and can be flipped off
-- independently to suppress that group of keymaps. -- independently to suppress that group of keymaps.
mappings = { mappings = {
@@ -47,7 +46,7 @@ M.defaults = {
mouse = false, -- <2-LeftMouse>, <RightMouse>, … (opt-in) mouse = false, -- <2-LeftMouse>, <RightMouse>, … (opt-in)
}, },
-- Phase 19: folding. `'lsp'` uses the server's `foldingRange` -- Folding. `'lsp'` uses the server's `foldingRange`
-- provider (Neovim 0.11+ wires it automatically). `'expr'` falls -- provider (Neovim 0.11+ wires it automatically). `'expr'` falls
-- back to a regex-based `foldexpr`. `'off'` skips folding setup. -- back to a regex-based `foldexpr`. `'off'` skips folding setup.
folding = 'lsp', folding = 'lsp',
+2 -2
View File
@@ -1,6 +1,6 @@
-- lua/nuwiki/ftplugin.lua — per-buffer hookup invoked by -- lua/nuwiki/ftplugin.lua — per-buffer hookup invoked by
-- `ftplugin/nuwiki.vim`. Centralises the Lua side of Phase 19 so the -- `ftplugin/nuwiki.vim`. Centralises the Lua side of the per-buffer
-- VimL ftplugin stays a one-liner regardless of which subgroups are -- glue so the VimL ftplugin stays a one-liner regardless of which subgroups are
-- enabled. -- enabled.
local M = {} local M = {}
+2 -2
View File
@@ -1,7 +1,7 @@
-- lua/nuwiki/health.lua — `:checkhealth nuwiki` target. -- lua/nuwiki/health.lua — `:checkhealth nuwiki` target.
-- --
-- v1.0 checks per SPEC §7.6 (binary, version, filetype, LSP attach). -- Core checks: binary, version, filetype, LSP attach.
-- v1.1 §12.10 additions: registered LSP commands, indexed wikis, HTML -- Additional checks: registered LSP commands, indexed wikis, HTML
-- output path writability, default keymaps installed. -- output path writability, default keymaps installed.
local M = {} local M = {}
+1 -1
View File
@@ -5,7 +5,7 @@
-- require('nuwiki').install() — install / build the language server -- require('nuwiki').install() — install / build the language server
-- require('nuwiki').health() — :checkhealth nuwiki target -- require('nuwiki').health() — :checkhealth nuwiki target
-- --
-- Per SPEC §6.2/§6.3 this layer is wiring only. Everything substantive -- This layer is wiring only. Everything substantive
-- happens in the Rust language server. -- happens in the Rust language server.
local M = {} local M = {}
+1 -1
View File
@@ -1,6 +1,6 @@
-- lua/nuwiki/install.lua — install / build the `nuwiki-ls` binary. -- lua/nuwiki/install.lua — install / build the `nuwiki-ls` binary.
-- --
-- Strategy (matches SPEC §7.2): -- Strategy:
-- 1. Look for `bin/nuwiki-ls[.exe]` next to the plugin. -- 1. Look for `bin/nuwiki-ls[.exe]` next to the plugin.
-- 2. If missing or `g:nuwiki_build_from_source` is set, build with cargo. -- 2. If missing or `g:nuwiki_build_from_source` is set, build with cargo.
-- 3. Otherwise try to download a release asset for the current target. -- 3. Otherwise try to download a release asset for the current target.
+7 -7
View File
@@ -16,7 +16,7 @@
-- wiki_prefix = true, -- <Leader>ww, <Leader>ws, <Leader>wi, … -- wiki_prefix = true, -- <Leader>ww, <Leader>ws, <Leader>wi, …
-- } -- }
-- --
-- Mappings that point at §13.1-deferred commands stub out to a -- Mappings that point at deferred commands stub out to a
-- "not yet implemented" notification — better than a silent no-op -- "not yet implemented" notification — better than a silent no-op
-- so users get parity *signalling*, not just parity in lhs. -- so users get parity *signalling*, not just parity in lhs.
@@ -133,12 +133,12 @@ local function open_above_with_bullet()
vim.api.nvim_feedkeys('O' .. prefix, 'n', false) vim.api.nvim_feedkeys('O' .. prefix, 'n', false)
end end
-- ===== Stub for deferred §13.1 commands ===== -- ===== Stub for deferred commands =====
local function deferred(name) local function deferred(name)
return function() return function()
vim.notify( vim.notify(
'nuwiki: ' .. name .. ' is not yet implemented — see SPEC §13.1', 'nuwiki: ' .. name .. ' is not yet implemented',
vim.log.levels.WARN vim.log.levels.WARN
) )
end end
@@ -218,8 +218,8 @@ function M.attach(bufnr, mappings)
map('n', 'gnt', cmd.next_task, { desc = 'nuwiki: next unfinished task' }, bufnr) map('n', 'gnt', cmd.next_task, { desc = 'nuwiki: next unfinished task' }, bufnr)
map('n', 'gln', cmd.cycle_list_item, { desc = 'nuwiki: increment checkbox' }, bufnr) map('n', 'gln', cmd.cycle_list_item, { desc = 'nuwiki: increment checkbox' }, bufnr)
map('x', 'gln', cmd.cycle_list_item, { desc = 'nuwiki: increment checkbox' }, bufnr) map('x', 'gln', cmd.cycle_list_item, { desc = 'nuwiki: increment checkbox' }, bufnr)
map('n', 'glp', cmd.cycle_list_item, { desc = 'nuwiki: decrement checkbox' }, bufnr) map('n', 'glp', cmd.cycle_list_item_back, { desc = 'nuwiki: decrement checkbox' }, bufnr)
map('x', 'glp', cmd.cycle_list_item, { desc = 'nuwiki: decrement checkbox' }, bufnr) map('x', 'glp', cmd.cycle_list_item_back, { desc = 'nuwiki: decrement checkbox' }, bufnr)
map('n', 'glx', cmd.reject_list_item, { desc = 'nuwiki: reject checkbox' }, bufnr) map('n', 'glx', cmd.reject_list_item, { desc = 'nuwiki: reject checkbox' }, bufnr)
map('x', 'glx', cmd.reject_list_item, { desc = 'nuwiki: reject checkbox' }, bufnr) map('x', 'glx', cmd.reject_list_item, { desc = 'nuwiki: reject checkbox' }, bufnr)
map('n', 'glh', function() cmd.list_change_level(-1, false) end, map('n', 'glh', function() cmd.list_change_level(-1, false) end,
@@ -235,8 +235,8 @@ function M.attach(bufnr, mappings)
map('n', 'gLr', cmd.list_renumber_all, map('n', 'gLr', cmd.list_renumber_all,
{ desc = 'nuwiki: renumber all lists' }, bufnr) { desc = 'nuwiki: renumber all lists' }, bufnr)
map('n', 'gl<Space>', cmd.list_remove_done, map('n', 'gl<Space>', cmd.list_remove_done,
{ desc = 'nuwiki: remove done items' }, bufnr) { desc = 'nuwiki: remove done items (current list)' }, bufnr)
map('n', 'gL<Space>', cmd.list_remove_done, map('n', 'gL<Space>', cmd.list_remove_done_all,
{ desc = 'nuwiki: remove done items (whole doc)' }, bufnr) { desc = 'nuwiki: remove done items (whole doc)' }, bufnr)
map('n', 'o', open_below_with_bullet, { desc = 'nuwiki: open below + bullet' }, bufnr) map('n', 'o', open_below_with_bullet, { desc = 'nuwiki: open below + bullet' }, bufnr)
map('n', 'O', open_above_with_bullet, { desc = 'nuwiki: open above + bullet' }, bufnr) map('n', 'O', open_above_with_bullet, { desc = 'nuwiki: open above + bullet' }, bufnr)
+1 -1
View File
@@ -4,7 +4,7 @@
" their config (lazy.nvim does this automatically through `opts`). On Vim " their config (lazy.nvim does this automatically through `opts`). On Vim
" we start the LSP client when a vimwiki buffer is loaded. " we start the LSP client when a vimwiki buffer is loaded.
" "
" Per SPEC §6.2 / §6.3 this file contains wiring only — all logic lives in " This file contains wiring only — all logic lives in
" the Rust language server. " the Rust language server.
if exists('g:loaded_nuwiki') if exists('g:loaded_nuwiki')
+2 -2
View File
@@ -1,8 +1,8 @@
" syntax/vimwiki.vim — default highlight groups for nuwiki. " syntax/vimwiki.vim — default highlight groups for nuwiki.
" "
" Two responsibilities: " Two responsibilities:
" 1. Default `@vimwiki*` highlight groups for the LSP semantic tokens " 1. Default `@vimwiki*` highlight groups for the LSP semantic tokens.
" (SPEC §11 P7). Loaded eagerly so the very first hover/highlight " Loaded eagerly so the very first hover/highlight
" cycle after server startup already has colours. " cycle after server startup already has colours.
" 2. A small regex-based fallback so static highlighting works on " 2. A small regex-based fallback so static highlighting works on
" buffers where the LSP isn't running yet. " buffers where the LSP isn't running yet.
-34
View File
@@ -1,34 +0,0 @@
#!/usr/bin/env bash
#
# test-personal-wiki.sh — open the user's real personal wiki via start-vim.sh.
#
# Reuses start-vim.sh's plumbing (binary build, vim-lsp clone, viminfo
# under the dev cache) but points the wiki root at ~/.vimwiki/personal_wiki
# and disables seed_wiki so we don't drop a scratch `Notes.wiki` into your
# real notes.
#
# Useful when reproducing a user-reported bug against actual content rather
# than the seeded smoke-test wiki. State (viminfo/sessions) still lives
# under $XDG_CACHE_HOME/nuwiki-dev/ so your real Vim install stays untouched.
#
# Usage:
# ./test-personal-wiki.sh # open index.wiki
# ./test-personal-wiki.sh path/to/note.wiki # open a specific file
# NUWIKI_PERSONAL_WIKI=/other/path ./test-personal-wiki.sh
# NUWIKI_DEV_SKIP_BUILD=1 ./test-personal-wiki.sh # reuse the cached binary
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WIKI_DIR="${NUWIKI_PERSONAL_WIKI:-$HOME/.vimwiki/personal_wiki}"
if [[ ! -d "$WIKI_DIR" ]]; then
echo "test-personal-wiki: $WIKI_DIR does not exist" >&2
echo "set NUWIKI_PERSONAL_WIKI to point at your wiki, or create the default path" >&2
exit 1
fi
export NUWIKI_DEV_WIKI="$WIKI_DIR"
export NUWIKI_DEV_NO_SEED=1
exec "$REPO_ROOT/start-vim.sh" "$@"