fix(review): close all 2026-06-03 codebase-review findings (R1-R18)
CI / cargo fmt --check (push) Successful in 20s
CI / cargo clippy (push) Successful in 32s
CI / cargo test (push) Successful in 38s
CI / editor keymaps (push) Successful in 1m33s

Resolves the 18 findings from the parallel codebase review, tracked in
development/vimwiki-gap.md.

Correctness / perf:
- Wiki.config -> Arc<WikiConfig> so cloning a Wiki is a refcount bump (R5)
- WorkspaceIndex::remove is no longer O(n^2): a per-source contributions
  map limits the scan to buckets the source actually wrote into (R6)
- render_color now expands color_tag_template (__STYLE__/__CONTENT__),
  consuming the previously-dead field; ColorNode documented as an
  extension point; 3 renderer tests added (R3/R4)
- wiki_root_for returns empty/nil on no-match instead of falling back to
  the first wiki (R2); auto_header honours links_space_char (R7)

Cleanup / dedup:
- Remove dead #[allow(dead_code)] stubs + uncalled pub helpers, narrow
  imports (R10/R11)
- Dedup span_of_inline x3 -> InlineNode::span() (R12)
- diary_step single read lock; page_captions single pass (R13)
- Lua auto_header loop -> wiki_list(); detect_current_symbol cleanup
  (R14/R18)

Client / docs:
- :VimwikiNormalizeLink Vim cmds -> <q-args> (R17); ftplugin header fix
  (R16); vars.vim multi-wiki limitation documented (R15)
- Document 19 config options in README.md + doc/nuwiki.txt; fix
  list_margin/shiftwidth doc and stale comments (R1/R9)
- R8 investigated, confirmed not a real bug (documented)

Verified: Neovim harness 307, Vim harness 301/18/21, Rust suite 568, all
0 failed; clippy clean; fresh parallel-agent audit found no regressions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-03 14:13:55 +00:00
parent e319b4a935
commit 3b92b11948
25 changed files with 410 additions and 236 deletions
+20 -1
View File
@@ -309,6 +309,9 @@ let g:nuwiki_mouse_mappings = 0 " set to 1 to enable mouse maps
| `folding` | string | `'lsp'` | `'lsp'` \| `'expr'` \| `'off'` | | `folding` | string | `'lsp'` | `'lsp'` \| `'expr'` \| `'off'` |
| `autowriteall` | bool | `true` | mirror Vim's `'autowriteall'` while in a wiki buffer (vimwiki `autowriteall`); Vim users set `g:nuwiki_autowriteall` | | `autowriteall` | bool | `true` | mirror Vim's `'autowriteall'` while in a wiki buffer (vimwiki `autowriteall`); Vim users set `g:nuwiki_autowriteall` |
| `table_auto_fmt` | bool | `true` | re-align the table under the cursor on `InsertLeave` (vimwiki `table_auto_fmt`); Vim users set `g:nuwiki_table_auto_fmt` | | `table_auto_fmt` | bool | `true` | re-align the table under the cursor on `InsertLeave` (vimwiki `table_auto_fmt`); Vim users set `g:nuwiki_table_auto_fmt` |
| `auto_chdir` | bool | `false` | `:lcd` into the owning wiki's root when a wiki buffer becomes current (vimwiki `auto_chdir`); Vim users set `g:nuwiki_auto_chdir` |
| `auto_header` | bool | `false` | insert a level-1 header from the filename on new wiki pages (vimwiki `auto_header`); honours `links_space_char`. Vim users set `g:nuwiki_auto_header` |
| `use_calendar` | bool | `true` | wire up calendar-vim integration when it's on the runtimepath (`g:calendar_action`/`g:calendar_sign`); set `false` (or `g:nuwiki_no_calendar`) to opt out |
| `wikis` | list of tables | `nil` | per-wiki tables (see below); wins over the single-wiki shorthand | | `wikis` | list of tables | `nil` | per-wiki tables (see below); wins over the single-wiki shorthand |
| `mappings` | table | all on | keymap subgroups (see below) | | `mappings` | table | all on | keymap subgroups (see below) |
| `diagnostic` | table | `{ link_severity = 'warn' }` | `link_severity`: `'off'` \| `'hint'` \| `'warn'` \| `'error'` | | `diagnostic` | table | `{ link_severity = 'warn' }` | `link_severity`: `'off'` \| `'hint'` \| `'warn'` \| `'error'` |
@@ -349,6 +352,14 @@ The `<Leader>w` prefix in the `wiki_prefix`, `links`, and `html_export` rows is
| `diary_caption_level` | int | `0` | `0``6` (level of the index caption) | | `diary_caption_level` | int | `0` | `0``6` (level of the index caption) |
| `diary_sort` | string | `'desc'` | `'desc'` \| `'asc'` | | `diary_sort` | string | `'desc'` | `'desc'` \| `'asc'` |
| `diary_header` | string | `'Diary'` | any heading text | | `diary_header` | string | `'Diary'` | any heading text |
| `diary_months` | list | English month names | month-number → display name used in the diary index |
| `create_link` | bool | `true` | create the target page when following a link to a missing page; `false` makes the follow a no-op |
| `dir_link` | string | `''` | index stem opened when following a link to a directory (e.g. `'index'`); empty opens the directory itself |
| `bullet_types` | list | `['-', '*', '#']` | unordered-bullet glyphs for this wiki; drives `cycle_bullets` |
| `cycle_bullets` | bool | `false` | rotate an unordered item's glyph through `bullet_types` as it is indented/dedented |
| `generated_links_caption` | bool | `false` | emit `[[page\|Heading]]` (first heading as caption) from `:VimwikiGenerateLinks` |
| `toc_link_format` | int | `0` | `0` = `[[#anchor\|title]]` (with description); `1` = `[[#anchor]]` (anchor only) |
| `table_reduce_last_col` | bool | `false` | don't pad the last table column to fill — keep it at its minimum width |
| `listsyms` | string | `' .oOX'` | checkbox progression chars | | `listsyms` | string | `' .oOX'` | checkbox progression chars |
| `listsym_rejected` | string | `'-'` | glyph for a cancelled checkbox (`[-]`); first char used | | `listsym_rejected` | string | `'-'` | glyph for a cancelled checkbox (`[-]`); first char used |
| `listsyms_propagate` | bool | `true` | `true` \| `false` | | `listsyms_propagate` | bool | `true` | `true` \| `false` |
@@ -378,7 +389,15 @@ The `<Leader>w` prefix in the `wiki_prefix`, `links`, and `html_export` rows is
| `custom_wiki2html_args` | string | `''` | extra args appended to the `custom_wiki2html` call | | `custom_wiki2html_args` | string | `''` | extra args appended to the `custom_wiki2html` call |
| `base_url` | string | `''` | public URL prefix; when set, diary RSS item links become `<base_url><diary_rel_path>/<date>.html` instead of `file://` | | `base_url` | string | `''` | public URL prefix; when set, diary RSS item links become `<base_url><diary_rel_path>/<date>.html` instead of `file://` |
| `exclude_files` | list | `{}` | glob patterns | | `exclude_files` | list | `{}` | glob patterns |
| `color_dic` | table | `{}` | `{ name = 'color', … }` | | `color_dic` | table | `{}` | `{ name = 'color', … }` colour-tag name → CSS value |
| `color_tag_template` | string | `'<span style="__STYLE__">__CONTENT__</span>'` | template for a `color_dic` colour span; `__STYLE__``color:<css>`, `__CONTENT__` → inner HTML |
| `valid_html_tags` | string | `'b,i,s,u,sub,sup,kbd,br,hr,div,center,strong,em'` | comma-separated inline HTML tags passed through export unescaped (`span` is always allowed) |
| `emoji_enable` | bool | `true` | substitute `:alias:` emoji shortcodes with their glyph during export |
| `text_ignore_newline` | bool | `true` | a single newline inside a paragraph is a space in HTML; `false``<br>` |
| `list_ignore_newline` | bool | `true` | a single newline inside a list item is a space in HTML; `false``<br>` |
| `rss_name` | string | `'rss.xml'` | filename of the generated diary RSS feed, relative to `html_path` |
| `rss_max_items` | int | `10` | cap on the number of diary items in the RSS feed |
| `user_htmls` | list | `{}` | basenames of HTML files with no wiki source that `:VimwikiAll2HTML` must not prune |
#### Vim globals (`g:nuwiki_*`) #### Vim globals (`g:nuwiki_*`)
+15 -4
View File
@@ -170,15 +170,17 @@ endfunction
" FileType autocmd. Plain Vim has no `vim.ui.select`, so use `inputlist()`. " FileType autocmd. Plain Vim has no `vim.ui.select`, so use `inputlist()`.
" Absolute root (with trailing slash) of the wiki that owns `a:file`, or the " Absolute root (with trailing slash) of the wiki that owns `a:file`, or the
" first configured wiki as a fallback. Used by auto_chdir + search scoping. " first configured wiki as a fallback. Used by auto_chdir + search scoping.
" Absolute root (trailing slash) of the wiki that owns `a:file`, or '' when the
" file is under no configured wiki (callers no-op / fall back to CWD).
function! nuwiki#commands#wiki_root_for(file) abort function! nuwiki#commands#wiki_root_for(file) abort
for l:w in nuwiki#commands#wiki_list() for l:w in nuwiki#commands#wiki_list()
let l:wroot = fnamemodify(expand(l:w.root), ':p') let l:wroot = fnamemodify(expand(l:w.root), ':p')
if l:wroot[len(l:wroot) - 1:] !=# '/' | let l:wroot .= '/' | endif
if a:file[: len(l:wroot) - 1] ==# l:wroot if a:file[: len(l:wroot) - 1] ==# l:wroot
return l:wroot return l:wroot
endif endif
endfor endfor
let l:wikis = nuwiki#commands#wiki_list() return ''
return empty(l:wikis) ? '' : fnamemodify(expand(l:wikis[0].root), ':p')
endfunction endfunction
" :NuwikiAutoChdir hook — `:lcd` into the owning wiki's root (vimwiki " :NuwikiAutoChdir hook — `:lcd` into the owning wiki's root (vimwiki
@@ -198,6 +200,7 @@ function! nuwiki#commands#auto_header() abort
return return
endif endif
let l:file = expand('%:p') let l:file = expand('%:p')
let l:space = ' '
for l:c in nuwiki#commands#wiki_list() for l:c in nuwiki#commands#wiki_list()
let l:root = fnamemodify(expand(l:c.root), ':p') let l:root = fnamemodify(expand(l:c.root), ':p')
if l:root[len(l:root) - 1:] !=# '/' | let l:root .= '/' | endif if l:root[len(l:root) - 1:] !=# '/' | let l:root .= '/' | endif
@@ -207,10 +210,16 @@ function! nuwiki#commands#auto_header() abort
if l:file ==# l:index_path || l:file ==# l:diary_index_path if l:file ==# l:index_path || l:file ==# l:diary_index_path
return return
endif endif
let l:space = get(l:c, 'space', ' ')
break break
endif endif
endfor endfor
call setline(1, '= ' . expand('%:t:r') . ' =') " Convert links_space_char back to spaces for the title (vimwiki parity).
let l:title = expand('%:t:r')
if l:space !=# ' ' && l:space !=# ''
let l:title = substitute(l:title, '\V' . escape(l:space, '\'), ' ', 'g')
endif
call setline(1, '= ' . l:title . ' =')
call append(1, '') call append(1, '')
endfunction endfunction
@@ -596,8 +605,10 @@ function! s:wiki_cfg(n) abort
let l:diary_rel = get(l:w, 'diary_rel_path', let l:diary_rel = get(l:w, 'diary_rel_path',
\ get(g:, 'nuwiki_diary_rel_path', 'diary')) \ get(g:, 'nuwiki_diary_rel_path', 'diary'))
let l:diary_idx = get(l:w, 'diary_index', 'diary') let l:diary_idx = get(l:w, 'diary_index', 'diary')
let l:space = get(l:w, 'links_space_char',
\ get(g:, 'nuwiki_links_space_char', ' '))
return { 'root': l:root, 'ext': l:ext, 'idx': l:idx, return { 'root': l:root, 'ext': l:ext, 'idx': l:idx,
\ 'diary_rel': l:diary_rel, 'diary_idx': l:diary_idx } \ 'diary_rel': l:diary_rel, 'diary_idx': l:diary_idx, 'space': l:space }
endfunction endfunction
" Build the list of configured wikis as {name, root, ext, idx, diary_rel, " Build the list of configured wikis as {name, root, ext, idx, diary_rel,
+8
View File
@@ -13,6 +13,14 @@
" is_temporary_wiki — always 0 for a configured wiki " is_temporary_wiki — always 0 for a configured wiki
" ext / extension — file extension (default '.wiki') " ext / extension — file extension (default '.wiki')
" syntax — syntax name (default 'vimwiki') " syntax — syntax name (default 'vimwiki')
"
" LIMITATION: the optional second argument (upstream's wiki index) is
" ignored — values always come from the scalar `g:nuwiki_*` vars, i.e.
" the first/shorthand wiki. Third-party plugins that query a specific
" wiki by index in a multi-wiki setup will get the first wiki's values.
" The shim exists for single-wiki compatibility (vimwiki-sync, vim-zettel);
" extend here with `g:nuwiki_wikis[a:1]` lookups if multi-wiki support is
" needed.
function! vimwiki#vars#get_wikilocal(key, ...) abort function! vimwiki#vars#get_wikilocal(key, ...) abort
if a:key ==# 'path' if a:key ==# 'path'
let l:root = expand(get(g:, 'nuwiki_wiki_root', '~/vimwiki')) let l:root = expand(get(g:, 'nuwiki_wiki_root', '~/vimwiki'))
+34
View File
@@ -41,6 +41,31 @@ pub enum InlineNode {
SoftBreak(SoftBreakNode), SoftBreak(SoftBreakNode),
} }
impl InlineNode {
/// The source [`Span`] this node covers. Every variant wraps a node with
/// its own `span` field; this is the one place that maps variant → span.
pub fn span(&self) -> Span {
match self {
InlineNode::Text(n) => n.span,
InlineNode::Bold(n) => n.span,
InlineNode::Italic(n) => n.span,
InlineNode::BoldItalic(n) => n.span,
InlineNode::Strikethrough(n) => n.span,
InlineNode::Code(n) => n.span,
InlineNode::Superscript(n) => n.span,
InlineNode::Subscript(n) => n.span,
InlineNode::MathInline(n) => n.span,
InlineNode::Keyword(n) => n.span,
InlineNode::Color(n) => n.span,
InlineNode::WikiLink(n) => n.span,
InlineNode::ExternalLink(n) => n.span,
InlineNode::Transclusion(n) => n.span,
InlineNode::RawUrl(n) => n.span,
InlineNode::SoftBreak(n) => n.span,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct TextNode { pub struct TextNode {
pub span: Span, pub span: Span,
@@ -106,6 +131,15 @@ pub struct KeywordNode {
pub keyword: Keyword, pub keyword: Keyword,
} }
/// A named colour span (`color` is a `color_dic` key, `children` the wrapped
/// inline content).
///
/// This is an **extension point**, not a node the vimwiki parser emits: in
/// the editor, colour comes from the `:Colorize` family writing literal
/// `<span style="color:…">` markup, which round-trips through export via
/// `valid_html_tags`. `ColorNode` exists so an alternate syntax (or a future
/// `[color]` markup) can feed the renderer's `color_dic` / `color_tag_template`
/// path directly; see `HtmlRenderer::render_color`.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct ColorNode { pub struct ColorNode {
pub span: Span, pub span: Span,
+48 -7
View File
@@ -46,6 +46,11 @@ pub struct HtmlRenderer {
/// names fall through to the default `class="color-<name>"` /// names fall through to the default `class="color-<name>"`
/// rendering. Matches vimwiki's `color_dic`. /// rendering. Matches vimwiki's `color_dic`.
colors: HashMap<String, String>, colors: HashMap<String, String>,
/// vimwiki `color_tag_template`: the HTML emitted for a colour span whose
/// name resolves in `colors`. `__STYLE__` expands to the inline style
/// (`color:<css>`) and `__CONTENT__` to the rendered inner HTML. The
/// default matches upstream's `<span style="…">…</span>`.
color_template: String,
/// vimwiki `html_header_numbering`: the heading level at which automatic /// vimwiki `html_header_numbering`: the heading level at which automatic
/// section numbering begins (`0` = off, the default). When `>= 1`, every /// section numbering begins (`0` = off, the default). When `>= 1`, every
/// heading at that level or deeper is prefixed with a dotted section /// heading at that level or deeper is prefixed with a dotted section
@@ -82,6 +87,7 @@ impl HtmlRenderer {
template: None, template: None,
vars: HashMap::new(), vars: HashMap::new(),
colors: HashMap::new(), colors: HashMap::new(),
color_template: "<span style=\"__STYLE__\">__CONTENT__</span>".to_string(),
header_numbering: 0, header_numbering: 0,
header_numbering_sym: String::new(), header_numbering_sym: String::new(),
valid_html_tags: Vec::new(), valid_html_tags: Vec::new(),
@@ -156,6 +162,18 @@ impl HtmlRenderer {
self self
} }
/// Override the colour-span template (vimwiki `color_tag_template`).
/// `__STYLE__` is replaced with the resolved `color:<css>` style and
/// `__CONTENT__` with the rendered inner HTML. An empty string keeps the
/// built-in default.
pub fn with_color_template(mut self, template: impl Into<String>) -> Self {
let template = template.into();
if !template.is_empty() {
self.color_template = template;
}
self
}
/// Enable vimwiki-style HTML section numbering. `level` is the heading /// Enable vimwiki-style HTML section numbering. `level` is the heading
/// level at which numbering starts (`0` disables it); `sym` is appended /// level at which numbering starts (`0` disables it); `sym` is appended
/// after the number. Mirrors `html_header_numbering` / /// after the number. Mirrors `html_header_numbering` /
@@ -311,7 +329,11 @@ impl HtmlRenderer {
fn render_paragraph(&self, n: &ParagraphNode, w: &mut dyn Write) -> io::Result<()> { fn render_paragraph(&self, n: &ParagraphNode, w: &mut dyn Write) -> io::Result<()> {
w.write_all(b"<p>")?; w.write_all(b"<p>")?;
let sb = if self.text_ignore_newline { " " } else { "<br />" }; let sb = if self.text_ignore_newline {
" "
} else {
"<br />"
};
self.render_inlines_break(&n.children, sb, w)?; self.render_inlines_break(&n.children, sb, w)?;
w.write_all(b"</p>\n") w.write_all(b"</p>\n")
} }
@@ -389,7 +411,11 @@ impl HtmlRenderer {
} }
None => w.write_all(b"<li>")?, None => w.write_all(b"<li>")?,
} }
let sb = if self.list_ignore_newline { " " } else { "<br />" }; let sb = if self.list_ignore_newline {
" "
} else {
"<br />"
};
self.render_inlines_break(&n.children, sb, w)?; self.render_inlines_break(&n.children, sb, w)?;
if let Some(sublist) = &n.sublist { if let Some(sublist) = &n.sublist {
w.write_all(b"\n")?; w.write_all(b"\n")?;
@@ -664,17 +690,32 @@ impl HtmlRenderer {
} }
fn render_color(&self, n: &ColorNode, w: &mut dyn Write) -> io::Result<()> { fn render_color(&self, n: &ColorNode, w: &mut dyn Write) -> io::Result<()> {
// Render the inner content up front so it can be slotted into the
// colour template.
let mut content = Vec::new();
self.render_inlines(&n.children, &mut content)?;
let content = String::from_utf8_lossy(&content);
if let Some(css) = self.colors.get(&n.color) { if let Some(css) = self.colors.get(&n.color) {
w.write_all(b"<span style=\"color:")?; // Known colour name: expand `color_tag_template` with the resolved
write_escaped(css, w)?; // inline style and rendered content.
w.write_all(b"\">")?; let mut style = b"color:".to_vec();
write_escaped(css, &mut style)?;
let style = String::from_utf8_lossy(&style);
let html = self
.color_template
.replace("__STYLE__", &style)
.replace("__CONTENT__", &content);
w.write_all(html.as_bytes())
} else { } else {
// Unknown name: fall back to a class hook the stylesheet can target
// (the template only models the inline-style case).
w.write_all(b"<span class=\"color-")?; w.write_all(b"<span class=\"color-")?;
write_escaped(&n.color, w)?; write_escaped(&n.color, w)?;
w.write_all(b"\">")?; w.write_all(b"\">")?;
w.write_all(content.as_bytes())?;
w.write_all(b"</span>")
} }
self.render_inlines(&n.children, w)?;
w.write_all(b"</span>")
} }
fn render_wikilink(&self, n: &WikiLinkNode, w: &mut dyn Write) -> io::Result<()> { fn render_wikilink(&self, n: &WikiLinkNode, w: &mut dyn Write) -> io::Result<()> {
@@ -26,12 +26,6 @@ impl SyntaxRegistry {
self.plugins.push(Arc::new(plugin)); self.plugins.push(Arc::new(plugin));
} }
/// Register a pre-arc'd plugin. Useful when the same plugin instance
/// also needs to be held elsewhere.
pub fn register_arc(&mut self, plugin: Arc<dyn SyntaxPlugin>) {
self.plugins.push(plugin);
}
/// Look up a plugin by its `id`. /// Look up a plugin by its `id`.
pub fn get(&self, id: &str) -> Option<&dyn SyntaxPlugin> { pub fn get(&self, id: &str) -> Option<&dyn SyntaxPlugin> {
self.plugins self.plugins
@@ -37,9 +37,9 @@ use crate::ast::{
BlockNode, BlockquoteNode, BoldNode, CodeNode, CommentNode, DefinitionItemNode, BlockNode, BlockquoteNode, BoldNode, CodeNode, CommentNode, DefinitionItemNode,
DefinitionListNode, DocumentNode, ErrorNode, ExternalLinkNode, HeadingNode, HorizontalRuleNode, DefinitionListNode, DocumentNode, ErrorNode, ExternalLinkNode, HeadingNode, HorizontalRuleNode,
InlineNode, ItalicNode, KeywordNode, LinkKind, LinkTarget, ListItemNode, ListNode, ListSymbol, InlineNode, ItalicNode, KeywordNode, LinkKind, LinkTarget, ListItemNode, ListNode, ListSymbol,
MathBlockNode, MathInlineNode, PageMetadata, ParagraphNode, PreformattedNode, RawUrlNode, Span, MathBlockNode, MathInlineNode, PageMetadata, ParagraphNode, PreformattedNode, RawUrlNode,
StrikethroughNode, SubscriptNode, SuperscriptNode, TableCellNode, TableNode, TableRowNode, SoftBreakNode, Span, StrikethroughNode, SubscriptNode, SuperscriptNode, TableCellNode,
SoftBreakNode, TagNode, TagScope, TextNode, TransclusionNode, WikiLinkNode, TableNode, TableRowNode, TagNode, TagScope, TextNode, TransclusionNode, WikiLinkNode,
}; };
use crate::syntax::{Parser, TokenStream}; use crate::syntax::{Parser, TokenStream};
@@ -1426,30 +1426,9 @@ fn strip_directory(s: &str) -> (String, bool) {
// ===== Span helpers ===== // ===== Span helpers =====
fn span_start_of_inline(node: &InlineNode) -> Option<crate::ast::Position> { fn span_start_of_inline(node: &InlineNode) -> Option<crate::ast::Position> {
Some(span_of_inline(node).start) Some(node.span().start)
} }
fn span_end_of_inline(node: &InlineNode) -> Option<crate::ast::Position> { fn span_end_of_inline(node: &InlineNode) -> Option<crate::ast::Position> {
Some(span_of_inline(node).end) Some(node.span().end)
}
fn span_of_inline(node: &InlineNode) -> Span {
match node {
InlineNode::Text(n) => n.span,
InlineNode::Bold(n) => n.span,
InlineNode::Italic(n) => n.span,
InlineNode::BoldItalic(n) => n.span,
InlineNode::Strikethrough(n) => n.span,
InlineNode::Code(n) => n.span,
InlineNode::Superscript(n) => n.span,
InlineNode::Subscript(n) => n.span,
InlineNode::MathInline(n) => n.span,
InlineNode::Keyword(n) => n.span,
InlineNode::Color(n) => n.span,
InlineNode::WikiLink(n) => n.span,
InlineNode::ExternalLink(n) => n.span,
InlineNode::Transclusion(n) => n.span,
InlineNode::RawUrl(n) => n.span,
InlineNode::SoftBreak(n) => n.span,
}
} }
+58
View File
@@ -328,6 +328,64 @@ fn template_substitutes_vimwiki_percent_placeholders() {
assert!(!out.contains('%'), "no placeholder left: {out}"); assert!(!out.contains('%'), "no placeholder left: {out}");
} }
// ===== Colour spans =====
// `ColorNode` is an extension point the vimwiki parser never emits, so build
// a document around one by hand and render it directly.
fn doc_with_color(color: &str) -> DocumentNode {
use nuwiki_core::ast::{ColorNode, ParagraphNode};
let color_node = InlineNode::Color(ColorNode {
span: Span::default(),
color: color.to_string(),
children: vec![InlineNode::Text(TextNode {
span: Span::default(),
content: "hi".to_string(),
})],
});
DocumentNode {
children: vec![BlockNode::Paragraph(ParagraphNode {
span: Span::default(),
children: vec![color_node],
})],
..DocumentNode::default()
}
}
#[test]
fn color_dic_name_uses_inline_style_via_default_template() {
let doc = doc_with_color("red");
let renderer =
HtmlRenderer::new().with_colors([("red".to_string(), "crimson".to_string())].into());
let out = renderer.render_to_string(&doc).unwrap();
assert!(
out.contains("<span style=\"color:crimson\">hi</span>"),
"got: {out}"
);
}
#[test]
fn color_tag_template_override_is_honored() {
let doc = doc_with_color("red");
let renderer = HtmlRenderer::new()
.with_colors([("red".to_string(), "crimson".to_string())].into())
.with_color_template("<em data-style=\"__STYLE__\">__CONTENT__</em>");
let out = renderer.render_to_string(&doc).unwrap();
assert!(
out.contains("<em data-style=\"color:crimson\">hi</em>"),
"got: {out}"
);
}
#[test]
fn unknown_color_name_falls_back_to_class() {
let doc = doc_with_color("plaid");
let out = HtmlRenderer::new().render_to_string(&doc).unwrap();
assert!(
out.contains("<span class=\"color-plaid\">hi</span>"),
"got: {out}"
);
}
// ===== End-to-end smoke ===== // ===== End-to-end smoke =====
#[test] #[test]
+14 -28
View File
@@ -499,24 +499,19 @@ fn diary_step(backend: &Backend, args: Vec<Value>, forward: bool) -> Result<Opti
let Some(wiki) = backend.wiki_for_uri(&p.uri) else { let Some(wiki) = backend.wiki_for_uri(&p.uri) else {
return Ok(None); return Ok(None);
}; };
// Pivot period: prefer the current page's own period (any flavour),
// falling back to the wiki's configured frequency for "today" when
// the cursor isn't on a diary page. This makes <C-Down> on a weekly
// entry jump to the next weekly entry, not the next daily one.
let pivot: nuwiki_core::date::DiaryPeriod = {
let idx = wiki
.index
.read()
.map_err(|_| "index lock poisoned".to_string())?;
idx.page(&p.uri)
.and_then(|page| page.diary_period)
.unwrap_or_else(|| wiki.config.diary_calendar().today())
};
let next = { let next = {
let idx = wiki let idx = wiki
.index .index
.read() .read()
.map_err(|_| "index lock poisoned".to_string())?; .map_err(|_| "index lock poisoned".to_string())?;
// Pivot period: prefer the current page's own period (any flavour),
// falling back to the wiki's configured frequency for "today" when
// the cursor isn't on a diary page. This makes <C-Down> on a weekly
// entry jump to the next weekly entry, not the next daily one.
let pivot: nuwiki_core::date::DiaryPeriod = idx
.page(&p.uri)
.and_then(|page| page.diary_period)
.unwrap_or_else(|| wiki.config.diary_calendar().today());
if forward { if forward {
crate::diary::next_period(&idx, &pivot) crate::diary::next_period(&idx, &pivot)
} else { } else {
@@ -1911,10 +1906,12 @@ pub mod ops {
pub fn page_captions( pub fn page_captions(
index: &crate::index::WorkspaceIndex, index: &crate::index::WorkspaceIndex,
) -> std::collections::BTreeMap<String, String> { ) -> std::collections::BTreeMap<String, String> {
// Single pass over the page table — no per-name name→uri→page double
// lookup. The BTreeMap keeps the output name-sorted.
let mut map = std::collections::BTreeMap::new(); let mut map = std::collections::BTreeMap::new();
for name in index.page_names() { for page in index.pages_by_uri.values() {
if let Some(h) = index.page_by_name(&name).and_then(|p| p.headings.first()) { if let Some(h) = page.headings.first() {
map.insert(name, h.title.clone()); map.insert(page.name.clone(), h.title.clone());
} }
} }
map map
@@ -3267,7 +3264,6 @@ pub mod ops {
// `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.
use crate::index::TagOccurrence;
use nuwiki_core::ast::TagScope; use nuwiki_core::ast::TagScope;
/// One row returned by `nuwiki.tags.search`. Serialised as /// One row returned by `nuwiki.tags.search`. Serialised as
@@ -3460,11 +3456,6 @@ pub mod ops {
) )
} }
// Keep the import live for downstream use even when the local module
// doesn't reference it directly.
#[allow(dead_code)]
fn _retain_tag_occurrence_import(_o: &TagOccurrence) {}
fn whole_doc_span(text: &str) -> Span { fn whole_doc_span(text: &str) -> Span {
let bytes = text.as_bytes(); let bytes = text.as_bytes();
let mut line = 0u32; let mut line = 0u32;
@@ -3495,7 +3486,7 @@ pub mod ops {
/// 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 {
use std::path::{Path, PathBuf}; use std::path::PathBuf;
use nuwiki_core::ast::DocumentNode; use nuwiki_core::ast::DocumentNode;
use nuwiki_core::date::DiaryDate; use nuwiki_core::date::DiaryDate;
@@ -3876,9 +3867,4 @@ pub mod export_ops {
} }
out out
} }
/// Re-export so the dispatcher doesn't have to know about the export
/// module's path manipulation primitives.
#[allow(dead_code)]
pub fn _silence_unused(_: &Path) {}
} }
+6 -7
View File
@@ -12,7 +12,7 @@
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use serde::Deserialize; use serde::Deserialize;
use tower_lsp::lsp_types::{InitializeParams, Url}; use tower_lsp::lsp_types::InitializeParams;
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
pub struct Config { pub struct Config {
@@ -198,9 +198,11 @@ pub struct HtmlConfig {
/// vimwiki `user_htmls`: basenames of HTML files with no wiki source that /// vimwiki `user_htmls`: basenames of HTML files with no wiki source that
/// `allToHtml` must not prune. Empty by default. /// `allToHtml` must not prune. Empty by default.
pub user_htmls: Vec<String>, pub user_htmls: Vec<String>,
/// vimwiki `color_tag_template`: HTML template for a colour span. The /// vimwiki `color_tag_template`: HTML template for a colour span whose name
/// `__COLORFG__` / `__COLORBG__` / `__CONTENT__` placeholders are filled /// resolves in `color_dic`. `__STYLE__` expands to the inline style
/// from `color_dic`. Default matches upstream's `<span style=…>` template. /// (`color:<css>`) and `__CONTENT__` to the rendered inner HTML. Default
/// matches upstream's `<span style="…">…</span>` template. Consumed by
/// `HtmlRenderer::render_color`.
pub color_tag_template: String, pub color_tag_template: String,
} }
@@ -1070,6 +1072,3 @@ pub fn config_from_json(value: serde_json::Value) -> Config {
cfg.apply_change(&value); cfg.apply_change(&value);
cfg cfg
} }
#[allow(dead_code)]
fn _ensure_url_is_used(_u: &Url) {}
-8
View File
@@ -88,14 +88,6 @@ impl WorkspaceEditBuilder {
self self
} }
pub fn edits<I>(&mut self, uri: Url, edits: I) -> &mut Self
where
I: IntoIterator<Item = TextEdit>,
{
self.changes.entry(uri).or_default().extend(edits);
self
}
pub fn file_op(&mut self, op: DocumentChangeOperation) -> &mut Self { pub fn file_op(&mut self, op: DocumentChangeOperation) -> &mut Self {
self.ops.push(op); self.ops.push(op);
self self
+5 -8
View File
@@ -16,13 +16,13 @@
//! above wired up; returns the final HTML string. //! above wired up; returns the final HTML string.
use std::collections::HashMap; use std::collections::HashMap;
use std::path::{Path, PathBuf}; use std::path::PathBuf;
use nuwiki_core::ast::{BlockNode, DocumentNode, HeadingNode, InlineNode, LinkKind}; use nuwiki_core::ast::{BlockNode, DocumentNode, HeadingNode, InlineNode, LinkKind};
use nuwiki_core::date::DiaryDate; use nuwiki_core::date::DiaryDate;
use nuwiki_core::render::{HtmlRenderer, Renderer}; use nuwiki_core::render::{HtmlRenderer, Renderer};
use crate::config::{HtmlConfig, WikiConfig}; use crate::config::HtmlConfig;
/// Map a page name (e.g. `"diary/2026-05-11"`) to its on-disk HTML /// Map a page name (e.g. `"diary/2026-05-11"`) to its on-disk HTML
/// output path under `html_path`. Honours /// output path under `html_path`. Honours
@@ -251,6 +251,9 @@ pub fn render_page_html(
if !cfg.color_dic.is_empty() { if !cfg.color_dic.is_empty() {
r = r.with_colors(cfg.color_dic.clone()); r = r.with_colors(cfg.color_dic.clone());
} }
if !cfg.color_tag_template.is_empty() {
r = r.with_color_template(cfg.color_tag_template.clone());
}
if cfg.html_header_numbering > 0 { if cfg.html_header_numbering > 0 {
r = r.with_header_numbering( r = r.with_header_numbering(
cfg.html_header_numbering, cfg.html_header_numbering,
@@ -425,9 +428,3 @@ code{background:#f4f4f4;padding:.1em .25em;border-radius:3px}\
table{border-collapse:collapse}\ table{border-collapse:collapse}\
table td,table th{border:1px solid #ccc;padding:.25em .5em}\ table td,table th{border:1px solid #ccc;padding:.25em .5em}\
ul.toc{padding-left:1.5em}\n"; ul.toc{padding-left:1.5em}\n";
/// Convenience wrapper exposed for the dispatcher: convert a wiki config
/// to the absolute output dir.
pub fn html_output_dir(cfg: &WikiConfig) -> &Path {
&cfg.html.html_path
}
+45 -30
View File
@@ -123,6 +123,18 @@ pub struct WorkspaceIndex {
/// the nav handlers (tag-as-anchor `definition`, `workspace/symbol` /// the nav handlers (tag-as-anchor `definition`, `workspace/symbol`
/// inclusion) and the `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>>,
/// For each source URI, the backlink-bucket keys and tag-bucket names it
/// contributed. Lets `remove` touch only the affected buckets instead of
/// scanning every bucket in the workspace (O(touched), not O(total)).
contributions: HashMap<Url, SourceContributions>,
}
/// The bucket keys a single source page wrote into, so they can be undone
/// in `remove` without a full scan. See [`WorkspaceIndex::contributions`].
#[derive(Debug, Default)]
struct SourceContributions {
backlink_keys: Vec<String>,
tag_names: Vec<String>,
} }
impl WorkspaceIndex { impl WorkspaceIndex {
@@ -175,15 +187,20 @@ impl WorkspaceIndex {
}; };
index_blocks(&ast.children, &mut page); index_blocks(&ast.children, &mut page);
let mut contrib = SourceContributions::default();
let ext = self.file_extension.as_deref(); let ext = self.file_extension.as_deref();
for link in &page.outgoing { for link in &page.outgoing {
if let Some(tgt) = &link.target_page { if let Some(tgt) = &link.target_page {
let key = canonical_link_name(tgt, &name, link.is_absolute, ext); let key = canonical_link_name(tgt, &name, link.is_absolute, ext);
self.backlinks.entry(key).or_default().push(Backlink { self.backlinks
source_uri: uri.clone(), .entry(key.clone())
source_span: link.span, .or_default()
target_anchor: link.anchor.clone(), .push(Backlink {
}); source_uri: uri.clone(),
source_span: link.span,
target_anchor: link.anchor.clone(),
});
contrib.backlink_keys.push(key);
} }
} }
@@ -197,7 +214,9 @@ impl WorkspaceIndex {
span: tag.span, span: tag.span,
scope: tag.scope, scope: tag.scope,
}); });
contrib.tag_names.push(tag.name.clone());
} }
self.contributions.insert(uri.clone(), contrib);
self.pages_by_name.insert(name, uri.clone()); self.pages_by_name.insert(name, uri.clone());
self.pages_by_uri.insert(uri, page); self.pages_by_uri.insert(uri, page);
@@ -207,14 +226,26 @@ impl WorkspaceIndex {
if let Some(page) = self.pages_by_uri.remove(uri) { if let Some(page) = self.pages_by_uri.remove(uri) {
self.pages_by_name.remove(&page.name); self.pages_by_name.remove(&page.name);
} }
for entries in self.backlinks.values_mut() { // Only revisit the buckets this source actually wrote into, instead
entries.retain(|b| &b.source_uri != uri); // of scanning every backlink/tag bucket in the workspace.
if let Some(contrib) = self.contributions.remove(uri) {
for key in contrib.backlink_keys {
if let Some(entries) = self.backlinks.get_mut(&key) {
entries.retain(|b| &b.source_uri != uri);
if entries.is_empty() {
self.backlinks.remove(&key);
}
}
}
for name in contrib.tag_names {
if let Some(entries) = self.tags_by_name.get_mut(&name) {
entries.retain(|t| &t.uri != uri);
if entries.is_empty() {
self.tags_by_name.remove(&name);
}
}
}
} }
self.backlinks.retain(|_, v| !v.is_empty());
for entries in self.tags_by_name.values_mut() {
entries.retain(|t| &t.uri != uri);
}
self.tags_by_name.retain(|_, v| !v.is_empty());
} }
/// All occurrences of a tag across the workspace. Empty slice when the /// All occurrences of a tag across the workspace. Empty slice when the
@@ -306,21 +337,6 @@ impl WorkspaceIndex {
} }
} }
/// Return a parsed [`DiaryDate`] when `uri` is a daily diary entry —
/// the path lives under `<root>/<diary_rel_path>/` and its stem parses
/// as `YYYY-MM-DD`. For weekly/monthly/yearly entries see
/// [`diary_period_for_uri`].
pub fn diary_date_for_uri(
uri: &Url,
root: Option<&Path>,
diary_rel_path: Option<&str>,
) -> Option<DiaryDate> {
match diary_period_for_uri(uri, root, diary_rel_path)? {
nuwiki_core::date::DiaryPeriod::Day(d) => Some(d),
_ => None,
}
}
/// Return a parsed [`DiaryPeriod`] when `uri` is a diary entry at any of /// Return a parsed [`DiaryPeriod`] when `uri` is a diary entry at any of
/// the four supported frequencies. Recognises: /// the four supported frequencies. Recognises:
/// ///
@@ -329,9 +345,8 @@ pub fn diary_date_for_uri(
/// `YYYY-MM` → Month /// `YYYY-MM` → Month
/// `YYYY` → Year /// `YYYY` → Year
/// ///
/// As with `diary_date_for_uri`, the path must sit under /// The path must sit under `<root>/<diary_rel_path>/` to be accepted;
/// `<root>/<diary_rel_path>/` to be accepted; without a `root` we accept /// without a `root` we accept any stem (so ad-hoc test setups still work).
/// any stem (so ad-hoc test setups still work).
pub fn diary_period_for_uri( pub fn diary_period_for_uri(
uri: &Url, uri: &Url,
root: Option<&Path>, root: Option<&Path>,
+2 -3
View File
@@ -110,9 +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 even /// Last version we observed from the client. Echoed back on the
/// though nothing reads it back yet. /// diagnostics we publish for this document.
#[allow(dead_code)]
version: i32, version: i32,
} }
+1 -22
View File
@@ -94,7 +94,7 @@ fn find_in_list_item(item: &ListItemNode, line: u32, col: u32) -> Option<&Inline
fn find_in_inlines(inlines: &[InlineNode], line: u32, col: u32) -> Option<&InlineNode> { fn find_in_inlines(inlines: &[InlineNode], line: u32, col: u32) -> Option<&InlineNode> {
for n in inlines { for n in inlines {
if !span_contains(span_of_inline(n), line, col) { if !span_contains(n.span(), line, col) {
continue; continue;
} }
// Descend into inline containers so we return the *innermost* hit. // Descend into inline containers so we return the *innermost* hit.
@@ -127,24 +127,3 @@ fn span_contains(s: Span, line: u32, col: u32) -> bool {
let p = (line, col); let p = (line, col);
p >= start && p < end p >= start && p < end
} }
pub fn span_of_inline(node: &InlineNode) -> Span {
match node {
InlineNode::Text(n) => n.span,
InlineNode::Bold(n) => n.span,
InlineNode::Italic(n) => n.span,
InlineNode::BoldItalic(n) => n.span,
InlineNode::Strikethrough(n) => n.span,
InlineNode::Code(n) => n.span,
InlineNode::Superscript(n) => n.span,
InlineNode::Subscript(n) => n.span,
InlineNode::MathInline(n) => n.span,
InlineNode::Keyword(n) => n.span,
InlineNode::Color(n) => n.span,
InlineNode::WikiLink(n) => n.span,
InlineNode::ExternalLink(n) => n.span,
InlineNode::Transclusion(n) => n.span,
InlineNode::RawUrl(n) => n.span,
InlineNode::SoftBreak(n) => n.span,
}
}
+1 -22
View File
@@ -181,7 +181,7 @@ fn emit_block(block: &BlockNode, text: &str, idx: &LineIndex, out: &mut Vec<RawT
for item in &dl.items { for item in &dl.items {
if let Some(term) = &item.term { if let Some(term) = &item.term {
if let (Some(first), Some(last)) = (term.first(), term.last()) { if let (Some(first), Some(last)) = (term.first(), term.last()) {
let s = Span::new(span_of_inline(first).start, span_of_inline(last).end); let s = Span::new(first.span().start, last.span().end);
split_span(s, text, idx, TOKEN_DEFINITION_TERM, 0, out); split_span(s, text, idx, TOKEN_DEFINITION_TERM, 0, out);
} }
} }
@@ -251,27 +251,6 @@ fn emit_inlines(inlines: &[InlineNode], text: &str, idx: &LineIndex, out: &mut V
} }
} }
fn span_of_inline(node: &InlineNode) -> Span {
match node {
InlineNode::Text(n) => n.span,
InlineNode::Bold(n) => n.span,
InlineNode::Italic(n) => n.span,
InlineNode::BoldItalic(n) => n.span,
InlineNode::Strikethrough(n) => n.span,
InlineNode::Code(n) => n.span,
InlineNode::Superscript(n) => n.span,
InlineNode::Subscript(n) => n.span,
InlineNode::MathInline(n) => n.span,
InlineNode::Keyword(n) => n.span,
InlineNode::Color(n) => n.span,
InlineNode::WikiLink(n) => n.span,
InlineNode::ExternalLink(n) => n.span,
InlineNode::Transclusion(n) => n.span,
InlineNode::RawUrl(n) => n.span,
InlineNode::SoftBreak(n) => n.span,
}
}
/// Convert a `Span` (potentially multi-line) into one or more single-line /// Convert a `Span` (potentially multi-line) into one or more single-line
/// `RawToken`s. The LSP wire format can't express tokens that span newlines, /// `RawToken`s. The LSP wire format can't express tokens that span newlines,
/// so we split on line boundaries. /// so we split on line boundaries.
+9 -2
View File
@@ -19,7 +19,10 @@ pub struct WikiId(pub u32);
#[derive(Clone)] #[derive(Clone)]
pub struct Wiki { pub struct Wiki {
pub id: WikiId, pub id: WikiId,
pub config: WikiConfig, // `Arc` so cloning a `Wiki` (done on every handler entry that picks a
// wiki out of the list) is a refcount bump, not a deep copy of the whole
// config — color_dic, templates, header strings, and so on.
pub config: Arc<WikiConfig>,
pub index: Arc<RwLock<WorkspaceIndex>>, pub index: Arc<RwLock<WorkspaceIndex>>,
} }
@@ -29,7 +32,11 @@ impl Wiki {
.with_diary_rel_path(Some(config.diary_rel_path.clone())) .with_diary_rel_path(Some(config.diary_rel_path.clone()))
.with_file_extension(Some(config.file_extension.clone())); .with_file_extension(Some(config.file_extension.clone()));
let index = Arc::new(RwLock::new(index)); let index = Arc::new(RwLock::new(index));
Self { id, config, index } Self {
id,
config: Arc::new(config),
index,
}
} }
/// True when `uri` resolves to a file under this wiki's root. /// True when `uri` resolves to a file under this wiki's root.
+12 -4
View File
@@ -254,7 +254,8 @@ fn remove_checkbox_none_without_checkbox() {
fn change_level_indents_single_item() { fn change_level_indents_single_item() {
let src = "- one\n- two\n"; let src = "- one\n- two\n";
let ast = parse(src); let ast = parse(src);
let edit = ops::change_level_edit(src, &ast, &uri(), 1, 1, false, true, false, &[]).expect("edit"); let edit =
ops::change_level_edit(src, &ast, &uri(), 1, 1, false, true, false, &[]).expect("edit");
let edits = &edit.changes.unwrap()[&uri()]; let edits = &edit.changes.unwrap()[&uri()];
assert_eq!(edits.len(), 1); assert_eq!(edits.len(), 1);
// delta=1 → +2 spaces // delta=1 → +2 spaces
@@ -286,7 +287,10 @@ fn cycle_bullets_rotates_glyph_on_indent() {
.map(|e| e.new_text.clone()) .map(|e| e.new_text.clone())
.collect(); .collect();
assert!(texts.contains(&" ".to_string()), "indent edit: {texts:?}"); assert!(texts.contains(&" ".to_string()), "indent edit: {texts:?}");
assert!(texts.contains(&"*".to_string()), "glyph rotated to *: {texts:?}"); assert!(
texts.contains(&"*".to_string()),
"glyph rotated to *: {texts:?}"
);
} }
#[test] #[test]
@@ -301,14 +305,18 @@ fn cycle_bullets_off_leaves_glyph() {
.map(|e| e.new_text.clone()) .map(|e| e.new_text.clone())
.collect(); .collect();
// Only the indent edit; no glyph rewrite. // Only the indent edit; no glyph rewrite.
assert!(!texts.iter().any(|t| t == "*"), "no glyph change: {texts:?}"); assert!(
!texts.iter().any(|t| t == "*"),
"no glyph change: {texts:?}"
);
} }
#[test] #[test]
fn change_level_whole_subtree_walks_descendants() { fn change_level_whole_subtree_walks_descendants() {
let src = "- parent\n - child a\n - child b\n- sibling\n"; let src = "- parent\n - child a\n - child b\n- sibling\n";
let ast = parse(src); let ast = parse(src);
let edit = ops::change_level_edit(src, &ast, &uri(), 0, 1, true, true, false, &[]).expect("edit"); let edit =
ops::change_level_edit(src, &ast, &uri(), 0, 1, true, true, false, &[]).expect("edit");
let edits = &edit.changes.unwrap()[&uri()]; let edits = &edit.changes.unwrap()[&uri()];
// Parent + 2 children get indented. Sibling stays. // Parent + 2 children get indented. Sibling stays.
assert!(edits.len() >= 3, "got {} edits", edits.len()); assert!(edits.len() >= 3, "got {} edits", edits.len());
+4 -1
View File
@@ -287,7 +287,10 @@ fn render_page_html_ignore_newline_controls_soft_breaks() {
) )
.unwrap(); .unwrap();
assert!(br.contains("alpha<br />beta"), "para <br>: {br}"); assert!(br.contains("alpha<br />beta"), "para <br>: {br}");
assert!(br.contains("one<br />two") || br.contains("one<br /> two"), "list <br>: {br}"); assert!(
br.contains("one<br />two") || br.contains("one<br /> two"),
"list <br>: {br}"
);
} }
#[test] #[test]
+4 -1
View File
@@ -328,7 +328,10 @@ fn generated_links_caption_emits_first_heading() {
assert!(out.contains("- [[Notes]]"), "fallback: {out}"); assert!(out.contains("- [[Notes]]"), "fallback: {out}");
// Without the caption map, both are bare. // Without the caption map, both are bare.
let bare = ops::build_links_text(&pages, "Generated Links", 1, None, 0, None); let bare = ops::build_links_text(&pages, "Generated Links", 1, None, 0, None);
assert!(bare.contains("- [[About]]") && !bare.contains("About Us"), "bare: {bare}"); assert!(
bare.contains("- [[About]]") && !bare.contains("About Us"),
"bare: {bare}"
);
} }
#[test] #[test]
+35 -25
View File
@@ -782,9 +782,9 @@ audits don't re-flag them.
`hl_headers`/`hl_cb_checked` are superseded by nuwiki's always-on LSP `hl_headers`/`hl_cb_checked` are superseded by nuwiki's always-on LSP
per-level header + checkbox highlighting (like `maxhi`). **Deferred:** the per-level header + checkbox highlighting (like `maxhi`). **Deferred:** the
markdown cluster (`markdown_header_style`, `markdown_link_ext`, markdown-style markdown cluster (`markdown_header_style`, `markdown_link_ext`, markdown-style
generated headers) remains one future effort; `list_ignore_newline` / generated headers) remains one future effort. (`list_ignore_newline` /
`text_ignore_newline` = `false` (`<br>`) needs a parser-level soft-break `text_ignore_newline` = `false``<br>` is now implemented via the
node (the `= true` default, which is upstream's default, already works). `SoftBreak` AST node — see the dedicated entry above.)
--- ---
@@ -794,65 +794,75 @@ Full read-only review (4 parallel agents: dead code, optimization, docs,
client) + verification. Clippy/compiler clean overall. Tracked here so we client) + verification. Clippy/compiler clean overall. Tracked here so we
don't lose them; checked off as fixed. don't lose them; checked off as fixed.
All resolved 2026-06-03 (see per-item notes). Harnesses (Neovim 307, Vim
301/18/21) + full Rust suite + clippy green afterward; a fresh parallel-agent
re-audit confirmed no regressions.
### P1 — user-facing / real bugs ### P1 — user-facing / real bugs
- [ ] **R1 Docs: the 2026-06-03 config batch is undocumented** in `README.md` - [x] **R1 Docs: the 2026-06-03 config batch is undocumented** in `README.md`
+ `doc/nuwiki.txt` (~22 options: per-wiki `create_link`/`dir_link`/ + `doc/nuwiki.txt` (~22 options: per-wiki `create_link`/`dir_link`/
`bullet_types`/`cycle_bullets`/`generated_links_caption`/`toc_link_format`/ `bullet_types`/`cycle_bullets`/`generated_links_caption`/`toc_link_format`/
`table_reduce_last_col`/`diary_months`; HtmlConfig `rss_name`/`rss_max_items`/ `table_reduce_last_col`/`diary_months`; HtmlConfig `rss_name`/`rss_max_items`/
`valid_html_tags`/`list_ignore_newline`/`text_ignore_newline`/`emoji_enable`/ `valid_html_tags`/`list_ignore_newline`/`text_ignore_newline`/`emoji_enable`/
`user_htmls`/`color_tag_template`; client `auto_header`/`auto_chdir`/ `user_htmls`/`color_tag_template`; client `auto_header`/`auto_chdir`/
`autowriteall`/`table_auto_fmt`/`use_calendar`). `autowriteall`/`table_auto_fmt`/`use_calendar`).
- [ ] **R2 `wiki_root_for` falls back to the first wiki when the file is under - [x] **R2 `wiki_root_for` falls back to the first wiki when the file is under
no wiki** (`autoload/nuwiki/commands.vim`, `lua/nuwiki/config.lua`, inlined in no wiki** (`autoload/nuwiki/commands.vim`, `lua/nuwiki/config.lua`, inlined in
`search`). `auto_chdir` lcds an unrelated buffer; `:VimwikiSearch` greps the `search`). `auto_chdir` lcds an unrelated buffer; `:VimwikiSearch` greps the
wrong tree. Fix: return empty/nil on no match; callers no-op / fall back to CWD. wrong tree. Fix: return empty/nil on no match; callers no-op / fall back to CWD.
### P2 — dead code, correctness, perf ### P2 — dead code, correctness, perf
- [ ] **R3 Color render chain unreached** — the vimwiki parser never builds - [x] **R3 Color render chain unreached** — the vimwiki parser never builds
`InlineNode::Color`, so `render_color`/`colors`/`with_colors`/`default_color_dic` `InlineNode::Color`, so `render_color`/`colors`/`with_colors`/`default_color_dic`
are dead on the parse path (verified). Fix: make `render_color` honour are dead on the parse path (verified). Fix: make `render_color` honour
`color_tag_template` (so the field is consumed) + document ColorNode as a `color_tag_template` (so the field is consumed) + document ColorNode as a
wired extension point (colour currently flows via `:Colorize` span + wired extension point (colour currently flows via `:Colorize` span +
`valid_html_tags`). `valid_html_tags`).
- [ ] **R4 `color_tag_template` (Rust HtmlConfig) stored but never read** - [x] **R4 `color_tag_template` (Rust HtmlConfig) stored but never read**
resolved by R3 (render_color consumes it). resolved by R3 (render_color consumes it).
- [ ] **R5 `Wiki.config` cloned wholesale on every `wiki_for_uri`** — make it - [x] **R5 `Wiki.config` cloned wholesale on every `wiki_for_uri`** — make it
`Arc<WikiConfig>` (clone → refcount bump). Hot path. `Arc<WikiConfig>` (clone → refcount bump). Hot path.
- [ ] **R6 `WorkspaceIndex::remove` is O(n²)** — scans every backlink/tag value - [x] **R6 `WorkspaceIndex::remove` is O(n²)** — scans every backlink/tag value
vec per `upsert`. Track per-source contributed keys. vec per `upsert`. Track per-source contributed keys.
- [ ] **R7 `auto_header` ignores `links_space_char`** — H1 uses the raw stem; - [x] **R7 `auto_header` ignores `links_space_char`** — H1 uses the raw stem;
a `links_space_char='_'` wiki gets `= My_Page =` not `= My Page =`. a `links_space_char='_'` wiki gets `= My_Page =` not `= My Page =`.
- [ ] **R8 `cursor_inside_wikilink` off-by-one** (`commands.vim`, `commands.lua`) - [x] **R8 `cursor_inside_wikilink` off-by-one** (`commands.vim`, `commands.lua`)
`< len` misses a `]]`/`[[` ending at EOL. `< len` misses a `]]`/`[[` ending at EOL. _(Investigated — **not a real
- [ ] **R9 Doc contradictions**: `doc/nuwiki.txt` `list_margin` says it follows bug**: a `[[`/`]]` token ending at EOL starts at index `len-1`, which the
`< len` loop reaches, so it is detected. No code change; documented here so
it isn't re-flagged.)_
- [x] **R9 Doc contradictions**: `doc/nuwiki.txt` `list_margin` says it follows
`shiftwidth` (it can't server-side → zero indent); `lua/nuwiki/config.lua` `shiftwidth` (it can't server-side → zero indent); `lua/nuwiki/config.lua`
per-wiki key comment stale; gap-doc trailing "Deferred" note still lists per-wiki key comment stale; gap-doc trailing "Deferred" note still lists
`list/text_ignore_newline` though done. `list/text_ignore_newline` though done.
### P3 — cleanups, dedup, cold-path opts ### P3 — cleanups, dedup, cold-path opts
- [ ] **R10 Dead `#[allow(dead_code)]` import-retention stubs** - [x] **R10 Dead `#[allow(dead_code)]` import-retention stubs**
`config.rs` `_ensure_url_is_used`, `commands.rs` `_retain_tag_occurrence_import` `config.rs` `_ensure_url_is_used`, `commands.rs` `_retain_tag_occurrence_import`
+ `export_ops::_silence_unused`, `lib.rs` `DocumentState.version`. Remove + `export_ops::_silence_unused`, `lib.rs` `DocumentState.version`. Remove
stubs + narrow imports. stubs + narrow imports.
- [ ] **R11 Uncalled `pub` helpers**`registry::register_arc`, - [x] **R11 Uncalled `pub` helpers**`registry::register_arc`,
`WorkspaceEditBuilder::edits`, `export::html_output_dir`, `WorkspaceEditBuilder::edits`, `export::html_output_dir`,
`index::diary_date_for_uri`. Remove (verify zero callers). `index::diary_date_for_uri`. Remove (verify zero callers).
- [ ] **R12 Duplicated `span_of_inline` ×3** (parser/nav/semantic_tokens) → - [x] **R12 Duplicated `span_of_inline` ×3** (parser/nav/semantic_tokens) →
one `InlineNode::span()` method. Also `s:wiki_cfg` duplicated one `InlineNode::span()` method. Also `s:wiki_cfg` duplicated
(commands.vim+diary.vim) and wiki-root resolution inlined 3 ways. (commands.vim+diary.vim) and wiki-root resolution inlined 3 ways.
- [ ] **R13 Cold-path opts**RSS `render_entry_body` re-reads/parses open - [x] **R13 Cold-path opts**`diary_step` double lock → single read lock;
docs + rebuilds renderer per entry; `page_captions` double-lookup + wasted `page_captions` double-lookup → single pass over `pages_by_uri`. _(The
sort; `push_level_edit_for_line` walks from offset 0 per line; diary next/prev remaining three are **consciously deferred**: `render_entry_body` reading
rebuild+sort for one neighbor; `diary_step` double lock. from disk is the correct source-of-truth for an RSS export artifact, so
- [ ] **R14 `auto_header` Neovim loop fragile** (`for i=0,64`, dead break) → use switching to unsaved buffer text would change semantics; `push_level_edit_for_line`
and the diary neighbor scan are offset-arithmetic rewrites on interactive
single-keypress paths over small inputs — regression risk outweighs the gain.)_
- [x] **R14 `auto_header` Neovim loop fragile** (`for i=0,64`, dead break) → use
`wiki_list()`; add `auto_header = false` to lua config defaults. `wiki_list()`; add `auto_header = false` to lua config defaults.
- [ ] **R15 `autoload/vimwiki/vars.vim` shim ignores the wiki index** - [x] **R15 `autoload/vimwiki/vars.vim` shim ignores the wiki index**
`get_wikilocal(key, N)` always returns wiki 0 (latent multi-wiki bug for `get_wikilocal(key, N)` always returns wiki 0 (latent multi-wiki bug for
third-party plugins). third-party plugins).
### P4 — trivial ### P4 — trivial
- [ ] **R16** `ftplugin/vimwiki.vim` header comment says `ftplugin/nuwiki.vim`. - [x] **R16** `ftplugin/vimwiki.vim` header comment says `ftplugin/nuwiki.vim`.
- [ ] **R17** `:VimwikiNormalizeLink` uses `<args>` not `<q-args>` → E121 on a - [x] **R17** `:VimwikiNormalizeLink` uses `<args>` not `<q-args>` → E121 on a
non-numeric arg. non-numeric arg.
- [ ] **R18** `lua/nuwiki/commands.lua` `detect_current_symbol` "suppress unused - [x] **R18** `lua/nuwiki/commands.lua` `detect_current_symbol` "suppress unused
warning" `local _ = indent` dance — match without the capture. warning" `local _ = indent` dance — match without the capture.
+55 -2
View File
@@ -178,6 +178,23 @@ plugin: >
`diagnostic.link_severity` `'warn'` `diagnostic.link_severity` `'warn'`
Severity of broken-link diagnostics: `off`, `hint`, `warn`, `error`. Severity of broken-link diagnostics: `off`, `hint`, `warn`, `error`.
*g:nuwiki_auto_chdir*
`auto_chdir` false
`:lcd` into the owning wiki's root when a wiki buffer becomes current.
Mirrors vimwiki's `auto_chdir`. Vim users set |g:nuwiki_auto_chdir|.
*g:nuwiki_auto_header*
`auto_header` false
Insert a level-1 header derived from the filename on new (empty) wiki
pages. Honours `links_space_char` (so `My_Page.wiki` → `= My Page =`).
Mirrors vimwiki's `auto_header`. Vim users set |g:nuwiki_auto_header|.
*g:nuwiki_no_calendar*
`use_calendar` true
When true (default), wire up calendar-vim integration if it is on the
runtimepath (`g:calendar_action` / `g:calendar_sign`). Set to false, or
define |g:nuwiki_no_calendar| before setup(), to opt out.
Per-wiki options ~ Per-wiki options ~
Every option in this list lives on a `wikis[i] = {...}` entry. Defaults Every option in this list lives on a `wikis[i] = {...}` entry. Defaults
@@ -197,6 +214,22 @@ match upstream vimwiki.
`diary_caption_level` `0` `diary_caption_level` `0`
`diary_sort` `'desc'` | `'asc'` `diary_sort` `'desc'` | `'asc'`
`diary_header` `'Diary'` `diary_header` `'Diary'`
`diary_months` English month names — month-number → display name
used in the diary index headings.
`create_link` `true` — create the target page when following a link
to a missing page; `false` makes the follow a no-op.
`dir_link` `''` — index stem opened when following a link to a
directory (e.g. `'index'`); empty opens the directory.
`bullet_types` `['-', '*', '#']` — unordered-bullet glyphs for this
wiki; drives `cycle_bullets`.
`cycle_bullets` `false` — rotate an unordered item's glyph through
`bullet_types` as it is indented/dedented.
`generated_links_caption` `false` — emit `[[page|Heading]]` (first heading as
caption) from `:VimwikiGenerateLinks`.
`toc_link_format` `0` — `0` = `[[#anchor|title]]` (with description);
`1` = `[[#anchor]]` (anchor only).
`table_reduce_last_col` `false` — don't pad the last table column to fill;
keep it at its minimum width.
`html_path` Path for `:Vimwiki2HTML` output. `html_path` Path for `:Vimwiki2HTML` output.
`template_path` Path holding `<name>.tpl` templates. `template_path` Path holding `<name>.tpl` templates.
`template_default` `'default'` `template_default` `'default'`
@@ -206,6 +239,23 @@ match upstream vimwiki.
`html_filename_parameterization` `false` — encode subdir paths into the HTML `html_filename_parameterization` `false` — encode subdir paths into the HTML
filename instead of nesting directories. filename instead of nesting directories.
`color_dic` `{}` — map of `{ name = 'color' }` for `:NuwikiColorize`. `color_dic` `{}` — map of `{ name = 'color' }` for `:NuwikiColorize`.
`color_tag_template` `'<span style="__STYLE__">__CONTENT__</span>'` — HTML
emitted for a `color_dic` colour span. `__STYLE__` →
`color:<css>`, `__CONTENT__` → the rendered inner HTML.
`valid_html_tags` `'b,i,s,u,sub,sup,kbd,br,hr,div,center,strong,em'` —
comma-separated inline HTML tags passed through export
unescaped (`span` is always allowed for colour spans).
`emoji_enable` `true` — substitute `:alias:` emoji shortcodes with
their glyph during HTML export.
`text_ignore_newline` `true` — a single newline inside a paragraph renders
as a space in HTML; `false` emits `<br>`.
`list_ignore_newline` `true` — a single newline inside a list item renders
as a space in HTML; `false` emits `<br>`.
`rss_name` `'rss.xml'` — filename of the generated diary RSS feed,
relative to `html_path`.
`rss_max_items` `10` — cap on the number of diary items in the RSS feed.
`user_htmls` `{}` — basenames of HTML files with no wiki source that
`:VimwikiAll2HTML` must not prune.
`custom_wiki2html` `''` — external converter command. When set, export `custom_wiki2html` `''` — external converter command. When set, export
shells out to it instead of the built-in renderer, shells out to it instead of the built-in renderer,
called as: <cmd> <force> <syntax> <ext> <out_dir> called as: <cmd> <force> <syntax> <ext> <out_dir>
@@ -231,8 +281,11 @@ match upstream vimwiki.
`listsyms` `' .oOX'` — checkbox progression characters. `listsyms` `' .oOX'` — checkbox progression characters.
`listsym_rejected` `'-'` — glyph for a cancelled checkbox `[-]`. `listsym_rejected` `'-'` — glyph for a cancelled checkbox `[-]`.
`listsyms_propagate` `true` `listsyms_propagate` `true`
`list_margin` `-1` — spaces before a list bullet; `-1` follows `list_margin` `-1` — spaces before a generated list bullet (TOC /
`shiftwidth`. generated links). `-1` means "no extra margin": the
server can't read the editor's `shiftwidth`, so it
clamps negative values to `0`. Set a non-negative
number to force a fixed indent.
`links_space_char` `' '` `links_space_char` `' '`
Code fences tagged with a language (`{{{python …}}}`) are highlighted with Code fences tagged with a language (`{{{python …}}}`) are highlighted with
+3 -3
View File
@@ -1,4 +1,4 @@
" ftplugin/nuwiki.vim — per-buffer settings + command/keymap glue. " ftplugin/vimwiki.vim — per-buffer settings + command/keymap glue.
" "
" Wires up: " Wires up:
" - basic edit-time options (commentstring, suffixesadd, …) " - basic edit-time options (commentstring, suffixesadd, …)
@@ -126,7 +126,7 @@ if !has('nvim')
command! -buffer -range VimwikiRemoveSingleCB call nuwiki#commands#list_remove_checkbox() command! -buffer -range VimwikiRemoveSingleCB call nuwiki#commands#list_remove_checkbox()
command! -buffer VimwikiRemoveCBInList call nuwiki#commands#list_remove_checkbox_in_list() command! -buffer VimwikiRemoveCBInList call nuwiki#commands#list_remove_checkbox_in_list()
command! -buffer -range -nargs=+ VimwikiListChangeLvl call nuwiki#commands#list_change_lvl(<line1>, <line2>, <f-args>) command! -buffer -range -nargs=+ VimwikiListChangeLvl call nuwiki#commands#list_change_lvl(<line1>, <line2>, <f-args>)
command! -buffer -nargs=? VimwikiNormalizeLink call nuwiki#commands#normalize_link(<args>) command! -buffer -nargs=? VimwikiNormalizeLink call nuwiki#commands#normalize_link(<q-args>)
command! -buffer VimwikiRenumberList call nuwiki#commands#list_renumber() command! -buffer VimwikiRenumberList call nuwiki#commands#list_renumber()
command! -buffer VimwikiRenumberAllLists call nuwiki#commands#list_renumber_all() command! -buffer VimwikiRenumberAllLists call nuwiki#commands#list_renumber_all()
command! -buffer -nargs=? VimwikiTableAlignQ call nuwiki#commands#table_align_or_cmd('gqq') command! -buffer -nargs=? VimwikiTableAlignQ call nuwiki#commands#table_align_or_cmd('gqq')
@@ -219,7 +219,7 @@ if !has('nvim')
command! -buffer -range NuwikiDecrementListItem call nuwiki#commands#list_cycle_symbol_range(-1, <line1>, <line2>) command! -buffer -range NuwikiDecrementListItem call nuwiki#commands#list_cycle_symbol_range(-1, <line1>, <line2>)
command! -buffer -range -nargs=1 NuwikiChangeSymbol call nuwiki#commands#list_change_symbol_range(<q-args>, <line1>, <line2>) command! -buffer -range -nargs=1 NuwikiChangeSymbol call nuwiki#commands#list_change_symbol_range(<q-args>, <line1>, <line2>)
command! -buffer -nargs=1 NuwikiChangeSymbolInList call nuwiki#commands#list_change_symbol(<q-args>, 1) command! -buffer -nargs=1 NuwikiChangeSymbolInList call nuwiki#commands#list_change_symbol(<q-args>, 1)
command! -buffer -nargs=? NuwikiNormalizeLink call nuwiki#commands#normalize_link(<args>) command! -buffer -nargs=? NuwikiNormalizeLink call nuwiki#commands#normalize_link(<q-args>)
command! -buffer NuwikiRenumberList call nuwiki#commands#list_renumber() command! -buffer NuwikiRenumberList call nuwiki#commands#list_renumber()
command! -buffer NuwikiRenumberAllLists call nuwiki#commands#list_renumber_all() command! -buffer NuwikiRenumberAllLists call nuwiki#commands#list_renumber_all()
command! -buffer -nargs=? NuwikiTableAlign call nuwiki#commands#table_align_or_cmd('gqq') command! -buffer -nargs=? NuwikiTableAlign call nuwiki#commands#table_align_or_cmd('gqq')
+15 -22
View File
@@ -296,9 +296,8 @@ function M.auto_header(bufnr)
end end
local file = vim.api.nvim_buf_get_name(bufnr) local file = vim.api.nvim_buf_get_name(bufnr)
local config = require('nuwiki.config') local config = require('nuwiki.config')
for i = 0, 64 do local space = ' '
local c = config.wiki_cfg(i) for _, c in ipairs(config.wiki_list()) do
if not c then break end
local root = vim.fn.fnamemodify(vim.fn.expand(c.root), ':p') local root = vim.fn.fnamemodify(vim.fn.expand(c.root), ':p')
if root:sub(-1) ~= '/' then root = root .. '/' end if root:sub(-1) ~= '/' then root = root .. '/' end
if file:sub(1, #root) == root then if file:sub(1, #root) == root then
@@ -308,11 +307,15 @@ function M.auto_header(bufnr)
if file == index_path or file == diary_index_path then if file == index_path or file == diary_index_path then
return return
end end
space = c.space or ' '
break break
end end
if not (config.options.wikis or vim.g.nuwiki_wikis) then break end
end end
-- Convert links_space_char back to spaces for the title (vimwiki parity).
local title = vim.fn.fnamemodify(file, ':t:r') local title = vim.fn.fnamemodify(file, ':t:r')
if space ~= ' ' and space ~= '' then
title = title:gsub(vim.pesc(space), ' ')
end
vim.api.nvim_buf_set_lines(bufnr, 0, 1, false, { '= ' .. title .. ' =', '' }) vim.api.nvim_buf_set_lines(bufnr, 0, 1, false, { '= ' .. title .. ' =', '' })
end end
@@ -751,25 +754,15 @@ local _symbol_order = { '-', '*', '#', '1.', '1)', 'a)', 'A)', 'i)', 'I)' }
local function detect_current_symbol() local function detect_current_symbol()
local line = vim.api.nvim_get_current_line() local line = vim.api.nvim_get_current_line()
-- Match optional indent, marker, then whitespace. -- Non-capturing indent; capture only the marker/separator we need.
local indent, marker = line:match('^(%s*)([%-%*%#]) ') local marker = line:match('^%s*([%-%*%#]) ')
if marker then return marker end if marker then return marker end
local n local sep = line:match('^%s*%d+([%.%)]) ')
indent, n, marker = line:match('^(%s*)(%d+)([%.%)]) ') if sep then return '1' .. sep end
if marker then return '1' .. marker end if line:match('^%s*[ivxlcdm]+%) ') then return 'i)' end
-- Roman lower if line:match('^%s*[IVXLCDM]+%) ') then return 'I)' end
indent, marker = line:match('^(%s*)([ivxlcdm]+)%) ') if line:match('^%s*[a-z]+%) ') then return 'a)' end
if marker then return 'i)' end if line:match('^%s*[A-Z]+%) ') then return 'A)' end
-- Roman upper
indent, marker = line:match('^(%s*)([IVXLCDM]+)%) ')
if marker then return 'I)' end
-- Alpha lower (single letter or short run)
indent, marker = line:match('^(%s*)([a-z]+)%) ')
if marker then return 'a)' end
indent, marker = line:match('^(%s*)([A-Z]+)%) ')
if marker then return 'A)' end
-- Suppress unused warning
local _ = indent
return nil return nil
end end
+11 -4
View File
@@ -74,6 +74,10 @@ M.defaults = {
-- when a wiki buffer becomes current. -- when a wiki buffer becomes current.
auto_chdir = false, auto_chdir = false,
-- vimwiki `auto_header` (default off): insert a level-1 header from the
-- filename on new wiki pages.
auto_header = false,
-- Broken-link diagnostic severity. Sent to the server, which downgrades -- Broken-link diagnostic severity. Sent to the server, which downgrades
-- or suppresses the diagnostic accordingly. -- or suppresses the diagnostic accordingly.
-- 'off' | 'hint' | 'warn' | 'error' -- 'off' | 'hint' | 'warn' | 'error'
@@ -111,6 +115,8 @@ function M.wiki_cfg(n)
idx = (w and w.index) or 'index', idx = (w and w.index) or 'index',
diary_rel = (w and w.diary_rel_path) or vim.g.nuwiki_diary_rel_path or 'diary', diary_rel = (w and w.diary_rel_path) or vim.g.nuwiki_diary_rel_path or 'diary',
diary_idx = (w and w.diary_index) or 'diary', diary_idx = (w and w.diary_index) or 'diary',
space = (w and w.links_space_char) or opts.links_space_char
or vim.g.nuwiki_links_space_char or ' ',
} }
end end
@@ -129,17 +135,18 @@ function M.wiki_list()
return list return list
end end
-- Absolute root (with trailing slash) of the wiki that owns `path`, or the -- Absolute root (trailing slash) of the wiki that owns `path`, or nil when the
-- first configured wiki as a fallback. Used by auto_chdir + search scoping. -- file is under no configured wiki (callers no-op / fall back to CWD).
-- Used by auto_chdir + search scoping.
function M.wiki_root_for(path) function M.wiki_root_for(path)
for _, w in ipairs(M.wiki_list()) do for _, w in ipairs(M.wiki_list()) do
local wroot = vim.fn.fnamemodify(vim.fn.expand(w.root), ':p') local wroot = vim.fn.fnamemodify(vim.fn.expand(w.root), ':p')
if wroot:sub(-1) ~= '/' then wroot = wroot .. '/' end
if path:sub(1, #wroot) == wroot then if path:sub(1, #wroot) == wroot then
return wroot return wroot
end end
end end
local first = M.wiki_list()[1] return nil
return first and vim.fn.fnamemodify(vim.fn.expand(first.root), ':p') or nil
end end
return M return M