parity(1): vimwiki per-wiki config keys, table colspan/rowspan,
named link commands, mouse maps
Parity audit Cluster 1 — small wins.
WikiConfig (server) extended with 14 vimwiki globals:
- `index` (`g:vimwiki_index`), wired into `:VimwikiIndex` so users
with a custom index page stem (e.g. `home`) get the right file.
- `diary_frequency`, `diary_start_week_day`, `diary_caption_level`,
`diary_sort`, `diary_header` — diary keys (weekly/monthly/yearly
semantics land in Cluster 4; the keys parse now so config
migration doesn't need to wait).
- `maxhi`, `listsyms`, `listsyms_propagate`, `list_margin`,
`links_space_char`, `nested_syntaxes`, `auto_toc` — list +
highlight knobs the renderer/handlers can consult.
All keys threaded through `RawWiki` → `WikiConfig::from(RawWiki)`,
defaults centralised in `wiki_defaults()` so `empty()`, `from_root()`,
the legacy single-wiki path, and the `RawWiki` impl stay in sync.
Lua front-door (`lua/nuwiki/config.lua`) now documents the multi-wiki
`wikis = {…}` shape with every accepted per-wiki key — users who
prefer Lua tables no longer have to round-trip through
`init_options` raw JSON.
HTML renderer (`crates/nuwiki-core/src/render/html.rs`):
- New `table_spans()` pre-pass resolves vimwiki's `>` (col_span) and
`\\/` (row_span) continuation markers into proper HTML
`colspan="N"` / `rowspan="N"` attributes on the lead cell. Continuation
cells emit nothing, matching what browsers expect.
- The old behaviour (emitting `class="col-span"` / `class="row-span"`
as visual markers) is gone — replaced with real merging so the
rendered HTML matches the source semantics.
Named ex-commands:
- `:VimwikiNextLink` / `:VimwikiPrevLink` — were keymapped only
(`<Tab>`/`<S-Tab>`); now have explicit `:` commands in both editor
paths, dispatching to new `nuwiki.commands.link_next` / `link_prev`
pure-Vim/Lua helpers.
- `:VimwikiBaddLink` — adds the link target's file to the buffer
list without switching focus. Goes through
`textDocument/definition` and runs `:badd <fname>` on the resolved
URI.
Mouse maps (opt-in via `mappings.mouse = true` in Lua or
`g:nuwiki_mouse_mappings = 1` in Vim):
- `<2-LeftMouse>` follows, `<S-2-LeftMouse>` / `<C-2-LeftMouse>`
follow in split/vsplit, `<MiddleMouse>` adds to buflist,
`<RightMouse>` goes back. Mirrors upstream vimwiki's defaults.
Tests: 7 new in `parity_cluster_1.rs` covering the per-wiki config
defaults + raw JSON parse, the legacy-single-wiki path, colspan
folding into a lead cell with no leftover class markers, rowspan
across rows, no-attrs when the table has no spans, and a sanity
paragraph render. Total 421 Rust tests pass; clippy clean; both
keymap harnesses still green (20 nvim + 12 vim).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -94,6 +94,44 @@ function! s:wiki_pick(notification) abort
|
||||
\ function('s:open_uri_from'))
|
||||
endfunction
|
||||
|
||||
" `:VimwikiNextLink` / `:VimwikiPrevLink` — pure-VimL `[[…]]` jumper.
|
||||
function! nuwiki#commands#link_next() abort
|
||||
call search('\[\[', 'W')
|
||||
endfunction
|
||||
function! nuwiki#commands#link_prev() abort
|
||||
call search('\[\[', 'bW')
|
||||
endfunction
|
||||
|
||||
" `:VimwikiBaddLink` — add the link target file to the buffer list
|
||||
" without focus. Routes through vim-lsp's textDocument/definition.
|
||||
function! nuwiki#commands#badd_link() abort
|
||||
if !s:has_vimlsp()
|
||||
echohl ErrorMsg | echom 'nuwiki: vim-lsp not loaded' | echohl None
|
||||
return
|
||||
endif
|
||||
call lsp#send_request(s:server, {
|
||||
\ 'method': 'textDocument/definition',
|
||||
\ 'params': s:cursor_position(),
|
||||
\ 'on_notification': function('s:badd_from_response'),
|
||||
\ })
|
||||
endfunction
|
||||
|
||||
function! s:badd_from_response(notification) abort
|
||||
let l:result = get(get(a:notification, 'response', {}), 'result', {})
|
||||
let l:uri = ''
|
||||
if type(l:result) == type({})
|
||||
let l:uri = get(l:result, 'uri', get(l:result, 'targetUri', ''))
|
||||
elseif type(l:result) == type([]) && !empty(l:result)
|
||||
let l:uri = get(l:result[0], 'uri', get(l:result[0], 'targetUri', ''))
|
||||
endif
|
||||
if l:uri ==# ''
|
||||
return
|
||||
endif
|
||||
let l:path = substitute(l:uri, '^file://', '', '')
|
||||
execute 'badd ' . fnameescape(l:path)
|
||||
echom 'nuwiki: added ' . l:path
|
||||
endfunction
|
||||
|
||||
" ===== Smart <CR>: follow or wrap-then-follow =====
|
||||
|
||||
function! s:cursor_inside_wikilink() abort
|
||||
|
||||
@@ -285,10 +285,16 @@ impl HtmlRenderer {
|
||||
}
|
||||
|
||||
fn render_table(&self, n: &TableNode, w: &mut dyn Write) -> io::Result<()> {
|
||||
// Pre-compute per-cell rowspan/colspan + suppression so we can
|
||||
// emit valid HTML colspan/rowspan attributes instead of CSS
|
||||
// class markers. `col_span` cells get folded into the colspan
|
||||
// of the lead cell to their left; `row_span` cells get folded
|
||||
// into the rowspan of the lead cell directly above.
|
||||
let layout = table_spans(n);
|
||||
w.write_all(b"<table>\n")?;
|
||||
let mut in_thead = false;
|
||||
let mut in_tbody = false;
|
||||
for row in &n.rows {
|
||||
for (row_idx, row) in n.rows.iter().enumerate() {
|
||||
if row.is_header {
|
||||
if !in_thead {
|
||||
w.write_all(b"<thead>\n")?;
|
||||
@@ -304,7 +310,7 @@ impl HtmlRenderer {
|
||||
in_tbody = true;
|
||||
}
|
||||
}
|
||||
self.render_table_row(row, w)?;
|
||||
self.render_table_row(row, row_idx, &layout, w)?;
|
||||
}
|
||||
if in_thead {
|
||||
w.write_all(b"</thead>\n")?;
|
||||
@@ -315,15 +321,29 @@ impl HtmlRenderer {
|
||||
w.write_all(b"</table>\n")
|
||||
}
|
||||
|
||||
fn render_table_row(&self, n: &TableRowNode, w: &mut dyn Write) -> io::Result<()> {
|
||||
fn render_table_row(
|
||||
&self,
|
||||
n: &TableRowNode,
|
||||
row_idx: usize,
|
||||
layout: &[Vec<CellLayout>],
|
||||
w: &mut dyn Write,
|
||||
) -> io::Result<()> {
|
||||
w.write_all(b"<tr>")?;
|
||||
for cell in &n.cells {
|
||||
// col_span / row_span here are *spec flags* meaning "this cell is
|
||||
// a continuation marker", which HTML expresses via the *previous*
|
||||
// cell's colspan/rowspan attrs. Computing those attrs requires
|
||||
// forward state on the renderer side; for now we emit class
|
||||
// markers so styling can show the span visually.
|
||||
self.render_table_cell(cell, n.is_header, w)?;
|
||||
for (col_idx, cell) in n.cells.iter().enumerate() {
|
||||
let info = layout
|
||||
.get(row_idx)
|
||||
.and_then(|r| r.get(col_idx))
|
||||
.copied()
|
||||
.unwrap_or(CellLayout::Lead {
|
||||
colspan: 1,
|
||||
rowspan: 1,
|
||||
});
|
||||
match info {
|
||||
CellLayout::Skip => continue,
|
||||
CellLayout::Lead { colspan, rowspan } => {
|
||||
self.render_table_cell(cell, n.is_header, colspan, rowspan, w)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
w.write_all(b"</tr>\n")
|
||||
}
|
||||
@@ -332,15 +352,19 @@ impl HtmlRenderer {
|
||||
&self,
|
||||
n: &TableCellNode,
|
||||
is_header: bool,
|
||||
colspan: u32,
|
||||
rowspan: u32,
|
||||
w: &mut dyn Write,
|
||||
) -> io::Result<()> {
|
||||
let tag = if is_header { "th" } else { "td" };
|
||||
let class = match (n.col_span, n.row_span) {
|
||||
(true, _) => " class=\"col-span\"",
|
||||
(_, true) => " class=\"row-span\"",
|
||||
_ => "",
|
||||
};
|
||||
write!(w, "<{tag}{class}>")?;
|
||||
write!(w, "<{tag}")?;
|
||||
if colspan > 1 {
|
||||
write!(w, " colspan=\"{colspan}\"")?;
|
||||
}
|
||||
if rowspan > 1 {
|
||||
write!(w, " rowspan=\"{rowspan}\"")?;
|
||||
}
|
||||
write!(w, ">")?;
|
||||
self.render_inlines(&n.children, w)?;
|
||||
write!(w, "</{tag}>")
|
||||
}
|
||||
@@ -528,6 +552,85 @@ impl HtmlRenderer {
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Table colspan/rowspan layout =====
|
||||
|
||||
/// Per-cell render decision: emit a lead cell with the computed spans,
|
||||
/// or skip (this cell is a continuation marker absorbed by its lead).
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum CellLayout {
|
||||
Lead { colspan: u32, rowspan: u32 },
|
||||
Skip,
|
||||
}
|
||||
|
||||
/// Walk every cell in the table and resolve `col_span` / `row_span`
|
||||
/// markers into HTML `colspan="N"` / `rowspan="N"` attributes on the
|
||||
/// preceding lead cell.
|
||||
///
|
||||
/// Rules:
|
||||
/// - `col_span: true` (`>` in vimwiki source) bumps the colspan of
|
||||
/// the lead cell to its *left* in the same row.
|
||||
/// - `row_span: true` (`\\/` in vimwiki source) bumps the rowspan
|
||||
/// of the lead cell *above* in the same column.
|
||||
/// - Markers that don't have a valid lead cell (first column /
|
||||
/// first row) render as a plain empty cell so the document still
|
||||
/// parses cleanly.
|
||||
fn table_spans(table: &TableNode) -> Vec<Vec<CellLayout>> {
|
||||
let rows = table.rows.len();
|
||||
if rows == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let cols = table.rows.iter().map(|r| r.cells.len()).max().unwrap_or(0);
|
||||
let mut layout: Vec<Vec<CellLayout>> = (0..rows)
|
||||
.map(|_| {
|
||||
(0..cols)
|
||||
.map(|_| CellLayout::Lead {
|
||||
colspan: 1,
|
||||
rowspan: 1,
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
for (ri, row) in table.rows.iter().enumerate() {
|
||||
for (ci, cell) in row.cells.iter().enumerate() {
|
||||
if cell.col_span {
|
||||
// Find the lead cell to the left in this row (skipping
|
||||
// already-skipped continuation slots) and bump it.
|
||||
let mut k = ci;
|
||||
while k > 0 {
|
||||
k -= 1;
|
||||
match layout[ri][k] {
|
||||
CellLayout::Lead {
|
||||
ref mut colspan, ..
|
||||
} => {
|
||||
*colspan += 1;
|
||||
layout[ri][ci] = CellLayout::Skip;
|
||||
break;
|
||||
}
|
||||
CellLayout::Skip => continue,
|
||||
}
|
||||
}
|
||||
} else if cell.row_span {
|
||||
// Walk up in the same column for the lead cell.
|
||||
let mut k = ri;
|
||||
while k > 0 {
|
||||
k -= 1;
|
||||
match layout[k][ci] {
|
||||
CellLayout::Lead {
|
||||
ref mut rowspan, ..
|
||||
} => {
|
||||
*rowspan += 1;
|
||||
layout[ri][ci] = CellLayout::Skip;
|
||||
break;
|
||||
}
|
||||
CellLayout::Skip => continue,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
layout
|
||||
}
|
||||
|
||||
// ===== Default link resolver =====
|
||||
|
||||
/// Default `LinkResolver`. Mirrors the conventions in SPEC.md §9:
|
||||
|
||||
@@ -704,10 +704,10 @@ fn wiki_open_index(
|
||||
let Some(wiki) = resolve_wiki_selector(backend, parsed.wiki.as_ref()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let index_path = wiki
|
||||
.config
|
||||
.root
|
||||
.join(format!("index{}", wiki.config.file_extension));
|
||||
let index_path = wiki.config.root.join(format!(
|
||||
"{}{}",
|
||||
wiki.config.index, wiki.config.file_extension
|
||||
));
|
||||
let Ok(uri) = Url::from_file_path(&index_path) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
+193
-19
@@ -27,6 +27,10 @@ pub struct WikiConfig {
|
||||
pub root: PathBuf,
|
||||
pub file_extension: String,
|
||||
pub syntax: String,
|
||||
/// Stem of the wiki's index page (vimwiki's `index`, default
|
||||
/// `"index"`). `<root>/<index><file_extension>` is what
|
||||
/// `:VimwikiIndex` opens.
|
||||
pub index: String,
|
||||
/// Subdirectory under `root` where diary entries live. Matches
|
||||
/// vimwiki's `g:vimwiki_diary_rel_path` (default `"diary"`).
|
||||
pub diary_rel_path: String,
|
||||
@@ -34,6 +38,42 @@ pub struct WikiConfig {
|
||||
/// Matches vimwiki's `g:vimwiki_diary_index` (default `"diary"`).
|
||||
/// The full path is `<root>/<diary_rel_path>/<diary_index>.wiki`.
|
||||
pub diary_index: String,
|
||||
/// `daily`/`weekly`/`monthly`/`yearly`. Picks the date format the
|
||||
/// diary commands use. v1.1 ships `daily`; the others fall through
|
||||
/// to vimwiki-style names but the commands are no-ops until
|
||||
/// Cluster 4 lands.
|
||||
pub diary_frequency: String,
|
||||
/// First day of the week (`monday`..`sunday`). Used by weekly
|
||||
/// diary entry naming.
|
||||
pub diary_start_week_day: String,
|
||||
/// Caption level for the diary index page headings (1 = h1).
|
||||
pub diary_caption_level: u8,
|
||||
/// Newest-first (`desc`) or oldest-first (`asc`) in the diary
|
||||
/// index.
|
||||
pub diary_sort: String,
|
||||
/// H1 text for the diary index page.
|
||||
pub diary_header: String,
|
||||
/// Maximum heading level treated as a *navigation* anchor — beyond
|
||||
/// it the LSP doesn't synthesise an anchor symbol. Matches
|
||||
/// vimwiki's `g:vimwiki_maxhi` (default `1`).
|
||||
pub maxhi: u8,
|
||||
/// Vimwiki's `g:vimwiki_listsyms`. The 5-character string maps
|
||||
/// fractional progress states for checkboxes; the i-th char (0..=4)
|
||||
/// is the rendered marker for state `i / 4`. Default: `" .oOX"`.
|
||||
pub listsyms: String,
|
||||
/// When toggling a parent list item, also cascade to descendants.
|
||||
pub listsyms_propagate: bool,
|
||||
/// `-1` keeps tight lists; positive integers indent list items by
|
||||
/// that many extra columns. Layout hint for the renderer.
|
||||
pub list_margin: i32,
|
||||
/// Character used in place of literal spaces inside wikilink
|
||||
/// targets when writing to disk (`_` by default).
|
||||
pub links_space_char: String,
|
||||
/// `nested_syntaxes` — code-block language → editor filetype map
|
||||
/// used for syntax-highlighting fenced blocks. Empty by default.
|
||||
pub nested_syntaxes: std::collections::HashMap<String, String>,
|
||||
/// Rebuild the page's TOC on save when set.
|
||||
pub auto_toc: bool,
|
||||
/// Phase 17 HTML export options. See SPEC §12.8.
|
||||
pub html: HtmlConfig,
|
||||
}
|
||||
@@ -119,19 +159,34 @@ impl WikiConfig {
|
||||
/// `initializationOptions` nor a workspace root. `name` is empty;
|
||||
/// callers shouldn't depend on it for routing.
|
||||
pub fn empty() -> Self {
|
||||
let d = wiki_defaults();
|
||||
Self {
|
||||
name: String::new(),
|
||||
root: PathBuf::new(),
|
||||
file_extension: ".wiki".into(),
|
||||
syntax: "vimwiki".into(),
|
||||
diary_rel_path: default_diary_rel_path(),
|
||||
diary_index: default_diary_index(),
|
||||
index: d.index,
|
||||
diary_rel_path: d.diary_rel_path,
|
||||
diary_index: d.diary_index,
|
||||
diary_frequency: d.diary_frequency,
|
||||
diary_start_week_day: d.diary_start_week_day,
|
||||
diary_caption_level: d.diary_caption_level,
|
||||
diary_sort: d.diary_sort,
|
||||
diary_header: d.diary_header,
|
||||
maxhi: d.maxhi,
|
||||
listsyms: d.listsyms,
|
||||
listsyms_propagate: d.listsyms_propagate,
|
||||
list_margin: d.list_margin,
|
||||
links_space_char: d.links_space_char,
|
||||
nested_syntaxes: d.nested_syntaxes,
|
||||
auto_toc: d.auto_toc,
|
||||
html: HtmlConfig::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_root(root: PathBuf) -> Self {
|
||||
let html = HtmlConfig::from_root(&root);
|
||||
let d = wiki_defaults();
|
||||
Self {
|
||||
name: root
|
||||
.file_name()
|
||||
@@ -140,8 +195,21 @@ impl WikiConfig {
|
||||
root,
|
||||
file_extension: ".wiki".into(),
|
||||
syntax: "vimwiki".into(),
|
||||
diary_rel_path: default_diary_rel_path(),
|
||||
diary_index: default_diary_index(),
|
||||
index: d.index,
|
||||
diary_rel_path: d.diary_rel_path,
|
||||
diary_index: d.diary_index,
|
||||
diary_frequency: d.diary_frequency,
|
||||
diary_start_week_day: d.diary_start_week_day,
|
||||
diary_caption_level: d.diary_caption_level,
|
||||
diary_sort: d.diary_sort,
|
||||
diary_header: d.diary_header,
|
||||
maxhi: d.maxhi,
|
||||
listsyms: d.listsyms,
|
||||
listsyms_propagate: d.listsyms_propagate,
|
||||
list_margin: d.list_margin,
|
||||
links_space_char: d.links_space_char,
|
||||
nested_syntaxes: d.nested_syntaxes,
|
||||
auto_toc: d.auto_toc,
|
||||
html,
|
||||
}
|
||||
}
|
||||
@@ -174,6 +242,75 @@ fn default_diary_index() -> String {
|
||||
"diary".to_string()
|
||||
}
|
||||
|
||||
fn default_index() -> String {
|
||||
"index".to_string()
|
||||
}
|
||||
|
||||
fn default_diary_frequency() -> String {
|
||||
"daily".to_string()
|
||||
}
|
||||
|
||||
fn default_diary_start_week_day() -> String {
|
||||
"monday".to_string()
|
||||
}
|
||||
|
||||
fn default_diary_sort() -> String {
|
||||
"desc".to_string()
|
||||
}
|
||||
|
||||
fn default_diary_header() -> String {
|
||||
"Diary".to_string()
|
||||
}
|
||||
|
||||
fn default_listsyms() -> String {
|
||||
" .oOX".to_string()
|
||||
}
|
||||
|
||||
fn default_links_space_char() -> String {
|
||||
" ".to_string()
|
||||
}
|
||||
|
||||
/// Fill in the v1.1 per-wiki keys that none of the constructors care
|
||||
/// about. Centralising the defaults here keeps `empty()`, `from_root()`,
|
||||
/// the legacy single-wiki path, and `From<RawWiki>` in sync.
|
||||
fn wiki_defaults() -> WikiDefaults {
|
||||
WikiDefaults {
|
||||
index: default_index(),
|
||||
diary_rel_path: default_diary_rel_path(),
|
||||
diary_index: default_diary_index(),
|
||||
diary_frequency: default_diary_frequency(),
|
||||
diary_start_week_day: default_diary_start_week_day(),
|
||||
diary_caption_level: 1,
|
||||
diary_sort: default_diary_sort(),
|
||||
diary_header: default_diary_header(),
|
||||
maxhi: 1,
|
||||
listsyms: default_listsyms(),
|
||||
listsyms_propagate: true,
|
||||
list_margin: -1,
|
||||
links_space_char: default_links_space_char(),
|
||||
nested_syntaxes: std::collections::HashMap::new(),
|
||||
auto_toc: false,
|
||||
}
|
||||
}
|
||||
|
||||
struct WikiDefaults {
|
||||
index: String,
|
||||
diary_rel_path: String,
|
||||
diary_index: String,
|
||||
diary_frequency: String,
|
||||
diary_start_week_day: String,
|
||||
diary_caption_level: u8,
|
||||
diary_sort: String,
|
||||
diary_header: String,
|
||||
maxhi: u8,
|
||||
listsyms: String,
|
||||
listsyms_propagate: bool,
|
||||
list_margin: i32,
|
||||
links_space_char: String,
|
||||
nested_syntaxes: std::collections::HashMap<String, String>,
|
||||
auto_toc: bool,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// Build a `Config` from `InitializeParams`.
|
||||
///
|
||||
@@ -194,19 +331,14 @@ impl Config {
|
||||
let wikis = if let Some(list) = raw.wikis {
|
||||
list.into_iter().map(WikiConfig::from).collect()
|
||||
} else if let Some(root) = raw.wiki_root.as_deref().and_then(expand_path) {
|
||||
let html = HtmlConfig::from_root(&root);
|
||||
vec![WikiConfig {
|
||||
name: root
|
||||
.file_name()
|
||||
.map(|s| s.to_string_lossy().into_owned())
|
||||
.unwrap_or_default(),
|
||||
root,
|
||||
file_extension: raw.file_extension.unwrap_or_else(|| ".wiki".into()),
|
||||
syntax: raw.syntax.unwrap_or_else(|| "vimwiki".into()),
|
||||
diary_rel_path: default_diary_rel_path(),
|
||||
diary_index: default_diary_index(),
|
||||
html,
|
||||
}]
|
||||
let mut wc = WikiConfig::from_root(root);
|
||||
if let Some(ext) = raw.file_extension {
|
||||
wc.file_extension = ext;
|
||||
}
|
||||
if let Some(s) = raw.syntax {
|
||||
wc.syntax = s;
|
||||
}
|
||||
vec![wc]
|
||||
} else if let Some(folder) = first_workspace_folder(params) {
|
||||
vec![WikiConfig::from_root(folder)]
|
||||
} else {
|
||||
@@ -289,6 +421,34 @@ struct RawWiki {
|
||||
exclude_files: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
color_dic: Option<std::collections::HashMap<String, String>>,
|
||||
// v1.1 per-wiki keys mirroring vimwiki globals. All optional so
|
||||
// existing single-wiki configs migrate without touching anything.
|
||||
#[serde(default)]
|
||||
index: Option<String>,
|
||||
#[serde(default)]
|
||||
diary_frequency: Option<String>,
|
||||
#[serde(default)]
|
||||
diary_start_week_day: Option<String>,
|
||||
#[serde(default)]
|
||||
diary_caption_level: Option<u8>,
|
||||
#[serde(default)]
|
||||
diary_sort: Option<String>,
|
||||
#[serde(default)]
|
||||
diary_header: Option<String>,
|
||||
#[serde(default)]
|
||||
maxhi: Option<u8>,
|
||||
#[serde(default)]
|
||||
listsyms: Option<String>,
|
||||
#[serde(default)]
|
||||
listsyms_propagate: Option<bool>,
|
||||
#[serde(default)]
|
||||
list_margin: Option<i32>,
|
||||
#[serde(default)]
|
||||
links_space_char: Option<String>,
|
||||
#[serde(default)]
|
||||
nested_syntaxes: Option<std::collections::HashMap<String, String>>,
|
||||
#[serde(default)]
|
||||
auto_toc: Option<bool>,
|
||||
}
|
||||
|
||||
impl From<RawWiki> for WikiConfig {
|
||||
@@ -329,13 +489,27 @@ impl From<RawWiki> for WikiConfig {
|
||||
if let Some(v) = r.color_dic {
|
||||
html.color_dic = v;
|
||||
}
|
||||
let d = wiki_defaults();
|
||||
WikiConfig {
|
||||
name: r.name.unwrap_or(derived_name),
|
||||
root,
|
||||
file_extension: r.file_extension.unwrap_or_else(|| ".wiki".into()),
|
||||
syntax: r.syntax.unwrap_or_else(|| "vimwiki".into()),
|
||||
diary_rel_path: r.diary_rel_path.unwrap_or_else(default_diary_rel_path),
|
||||
diary_index: r.diary_index.unwrap_or_else(default_diary_index),
|
||||
index: r.index.unwrap_or(d.index),
|
||||
diary_rel_path: r.diary_rel_path.unwrap_or(d.diary_rel_path),
|
||||
diary_index: r.diary_index.unwrap_or(d.diary_index),
|
||||
diary_frequency: r.diary_frequency.unwrap_or(d.diary_frequency),
|
||||
diary_start_week_day: r.diary_start_week_day.unwrap_or(d.diary_start_week_day),
|
||||
diary_caption_level: r.diary_caption_level.unwrap_or(d.diary_caption_level),
|
||||
diary_sort: r.diary_sort.unwrap_or(d.diary_sort),
|
||||
diary_header: r.diary_header.unwrap_or(d.diary_header),
|
||||
maxhi: r.maxhi.unwrap_or(d.maxhi),
|
||||
listsyms: r.listsyms.unwrap_or(d.listsyms),
|
||||
listsyms_propagate: r.listsyms_propagate.unwrap_or(d.listsyms_propagate),
|
||||
list_margin: r.list_margin.unwrap_or(d.list_margin),
|
||||
links_space_char: r.links_space_char.unwrap_or(d.links_space_char),
|
||||
nested_syntaxes: r.nested_syntaxes.unwrap_or(d.nested_syntaxes),
|
||||
auto_toc: r.auto_toc.unwrap_or(d.auto_toc),
|
||||
html,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
//! Cluster 1 of the parity-audit punch list: per-wiki config keys
|
||||
//! (vimwiki globals shipped through `initializationOptions`) and the
|
||||
//! HTML renderer's table-span attributes.
|
||||
|
||||
use nuwiki_core::ast::{
|
||||
BlockNode, DocumentNode, InlineNode, ParagraphNode, Span, TableCellNode, TableNode,
|
||||
TableRowNode, TextNode,
|
||||
};
|
||||
use nuwiki_core::render::{HtmlRenderer, Renderer};
|
||||
use nuwiki_lsp::config::{config_from_json, WikiConfig};
|
||||
|
||||
// ===== Per-wiki config keys =====
|
||||
|
||||
#[test]
|
||||
fn defaults_carry_vimwiki_per_wiki_keys() {
|
||||
let cfg = WikiConfig::empty();
|
||||
assert_eq!(cfg.index, "index");
|
||||
assert_eq!(cfg.diary_frequency, "daily");
|
||||
assert_eq!(cfg.diary_start_week_day, "monday");
|
||||
assert_eq!(cfg.diary_caption_level, 1);
|
||||
assert_eq!(cfg.diary_sort, "desc");
|
||||
assert_eq!(cfg.diary_header, "Diary");
|
||||
assert_eq!(cfg.maxhi, 1);
|
||||
assert_eq!(cfg.listsyms, " .oOX");
|
||||
assert!(cfg.listsyms_propagate);
|
||||
assert_eq!(cfg.list_margin, -1);
|
||||
assert_eq!(cfg.links_space_char, " ");
|
||||
assert!(cfg.nested_syntaxes.is_empty());
|
||||
assert!(!cfg.auto_toc);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_wiki_parses_every_new_key() {
|
||||
let cfg = config_from_json(serde_json::json!({
|
||||
"wikis": [{
|
||||
"root": "/tmp/w",
|
||||
"index": "home",
|
||||
"diary_frequency": "weekly",
|
||||
"diary_start_week_day": "sunday",
|
||||
"diary_caption_level": 2,
|
||||
"diary_sort": "asc",
|
||||
"diary_header": "Journal",
|
||||
"maxhi": 6,
|
||||
"listsyms": "abcde",
|
||||
"listsyms_propagate": false,
|
||||
"list_margin": 2,
|
||||
"links_space_char": "_",
|
||||
"nested_syntaxes": { "python": "python", "ruby": "ruby" },
|
||||
"auto_toc": true,
|
||||
}],
|
||||
}));
|
||||
let w = &cfg.wikis[0];
|
||||
assert_eq!(w.index, "home");
|
||||
assert_eq!(w.diary_frequency, "weekly");
|
||||
assert_eq!(w.diary_start_week_day, "sunday");
|
||||
assert_eq!(w.diary_caption_level, 2);
|
||||
assert_eq!(w.diary_sort, "asc");
|
||||
assert_eq!(w.diary_header, "Journal");
|
||||
assert_eq!(w.maxhi, 6);
|
||||
assert_eq!(w.listsyms, "abcde");
|
||||
assert!(!w.listsyms_propagate);
|
||||
assert_eq!(w.list_margin, 2);
|
||||
assert_eq!(w.links_space_char, "_");
|
||||
assert_eq!(
|
||||
w.nested_syntaxes.get("python").map(String::as_str),
|
||||
Some("python")
|
||||
);
|
||||
assert!(w.auto_toc);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_single_wiki_shape_picks_up_defaults() {
|
||||
let cfg = config_from_json(serde_json::json!({
|
||||
"wiki_root": "/tmp/legacy",
|
||||
}));
|
||||
assert_eq!(cfg.wikis.len(), 1);
|
||||
assert_eq!(cfg.wikis[0].index, "index");
|
||||
assert_eq!(cfg.wikis[0].diary_frequency, "daily");
|
||||
}
|
||||
|
||||
// ===== Table colspan/rowspan =====
|
||||
|
||||
fn cell(text: &str, col_span: bool, row_span: bool) -> TableCellNode {
|
||||
let s = Span::default();
|
||||
TableCellNode {
|
||||
span: s,
|
||||
children: vec![InlineNode::Text(TextNode {
|
||||
span: s,
|
||||
content: text.into(),
|
||||
})],
|
||||
col_span,
|
||||
row_span,
|
||||
}
|
||||
}
|
||||
|
||||
fn table(rows: Vec<Vec<TableCellNode>>, has_header: bool) -> DocumentNode {
|
||||
let span = Span::default();
|
||||
let rows: Vec<TableRowNode> = rows
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, cells)| TableRowNode {
|
||||
span,
|
||||
cells,
|
||||
is_header: has_header && i == 0,
|
||||
})
|
||||
.collect();
|
||||
DocumentNode {
|
||||
span,
|
||||
metadata: Default::default(),
|
||||
children: vec![BlockNode::Table(TableNode {
|
||||
span,
|
||||
rows,
|
||||
has_header,
|
||||
})],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colspan_marker_folds_into_lead_cell() {
|
||||
// | a | b | c |
|
||||
// | d | > | e | → second row has b spanning 2 cols (cell index 1 is >)
|
||||
let doc = table(
|
||||
vec![
|
||||
vec![
|
||||
cell("a", false, false),
|
||||
cell("b", false, false),
|
||||
cell("c", false, false),
|
||||
],
|
||||
vec![
|
||||
cell("d", false, false),
|
||||
cell(">", true, false),
|
||||
cell("e", false, false),
|
||||
],
|
||||
],
|
||||
false,
|
||||
);
|
||||
let out = HtmlRenderer::new().render_to_string(&doc).unwrap();
|
||||
assert!(out.contains(r#"<td colspan="2">d</td>"#), "got: {out}");
|
||||
assert!(!out.contains(r#"class="col-span""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rowspan_marker_folds_into_lead_cell_above() {
|
||||
let doc = table(
|
||||
vec![
|
||||
vec![cell("a", false, false), cell("b", false, false)],
|
||||
vec![cell("c", false, false), cell("\\/", false, true)],
|
||||
],
|
||||
false,
|
||||
);
|
||||
let out = HtmlRenderer::new().render_to_string(&doc).unwrap();
|
||||
assert!(out.contains(r#"<td rowspan="2">b</td>"#), "got: {out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_spans_emits_no_attrs() {
|
||||
let doc = table(
|
||||
vec![
|
||||
vec![cell("a", false, false), cell("b", false, false)],
|
||||
vec![cell("c", false, false), cell("d", false, false)],
|
||||
],
|
||||
false,
|
||||
);
|
||||
let out = HtmlRenderer::new().render_to_string(&doc).unwrap();
|
||||
assert!(!out.contains("colspan="));
|
||||
assert!(!out.contains("rowspan="));
|
||||
}
|
||||
|
||||
// Make sure ParagraphNode is exercised so the import stays live.
|
||||
#[test]
|
||||
fn paragraph_render_is_unchanged() {
|
||||
let p = DocumentNode {
|
||||
span: Span::default(),
|
||||
metadata: Default::default(),
|
||||
children: vec![BlockNode::Paragraph(ParagraphNode {
|
||||
span: Span::default(),
|
||||
children: vec![InlineNode::Text(TextNode {
|
||||
span: Span::default(),
|
||||
content: "hi".into(),
|
||||
})],
|
||||
})],
|
||||
};
|
||||
let out = HtmlRenderer::new().render_to_string(&p).unwrap();
|
||||
assert!(out.contains("hi"));
|
||||
}
|
||||
@@ -41,6 +41,9 @@ if !has('nvim')
|
||||
command! -buffer VimwikiFollowLink LspDefinition
|
||||
command! -buffer VimwikiBacklinks LspReferences
|
||||
command! -buffer VWB LspReferences
|
||||
command! -buffer VimwikiNextLink call nuwiki#commands#link_next()
|
||||
command! -buffer VimwikiPrevLink call nuwiki#commands#link_prev()
|
||||
command! -buffer VimwikiBaddLink call nuwiki#commands#badd_link()
|
||||
command! -buffer -nargs=1 VimwikiSearch lvimgrep /<args>/ **
|
||||
command! -buffer -nargs=1 VWS lvimgrep /<args>/ **
|
||||
command! -buffer -nargs=1 VimwikiGoto call nuwiki#commands#wiki_goto_page(<q-args>)
|
||||
@@ -181,6 +184,15 @@ if !has('nvim')
|
||||
nnoremap <silent><buffer> <Leader>whh :call nuwiki#commands#export_browse()<CR>
|
||||
nnoremap <silent><buffer> <Leader>wha :call nuwiki#commands#export_all()<CR>
|
||||
|
||||
" Mouse (opt-in via g:nuwiki_mouse_mappings = 1).
|
||||
if get(g:, 'nuwiki_mouse_mappings', 0)
|
||||
nnoremap <silent><buffer> <2-LeftMouse> :call nuwiki#commands#follow_link_or_create()<CR>
|
||||
nnoremap <silent><buffer> <S-2-LeftMouse> :split<CR>:call nuwiki#commands#follow_link_or_create()<CR>
|
||||
nnoremap <silent><buffer> <C-2-LeftMouse> :vsplit<CR>:call nuwiki#commands#follow_link_or_create()<CR>
|
||||
nnoremap <silent><buffer> <MiddleMouse> :call nuwiki#commands#badd_link()<CR>
|
||||
nnoremap <silent><buffer> <RightMouse> <C-o>
|
||||
endif
|
||||
|
||||
" Tables
|
||||
nnoremap <silent><buffer> gqq :call nuwiki#commands#table_align()<CR>
|
||||
nnoremap <silent><buffer> gq1 :call nuwiki#commands#table_align()<CR>
|
||||
@@ -216,6 +228,9 @@ command! -buffer VimwikiSplitLink split | lua vim.lsp.buf.definition()
|
||||
command! -buffer VimwikiVSplitLink vsplit | lua vim.lsp.buf.definition()
|
||||
command! -buffer VimwikiTabnewLink tabnew | lua vim.lsp.buf.definition()
|
||||
command! -buffer VimwikiTabDropLink tabnew | lua vim.lsp.buf.definition()
|
||||
command! -buffer VimwikiNextLink lua require('nuwiki.commands').link_next()
|
||||
command! -buffer VimwikiPrevLink lua require('nuwiki.commands').link_prev()
|
||||
command! -buffer VimwikiBaddLink lua require('nuwiki.commands').badd_link()
|
||||
command! -buffer -nargs=1 VimwikiGoto lua require('nuwiki.commands').wiki_goto_page(<q-args>)
|
||||
command! -buffer VimwikiBacklinks lua vim.lsp.buf.references()
|
||||
command! -buffer VWB lua vim.lsp.buf.references()
|
||||
|
||||
@@ -165,6 +165,47 @@ end
|
||||
--- 3. Press inside an existing `[[…]]` follows immediately.
|
||||
--- "Not on a word" falls through to plain definition so users can
|
||||
--- still chord a definition request anywhere.
|
||||
--- Jump to the next / previous `[[…]]` on or after the cursor.
|
||||
--- Public so `:VimwikiNextLink` / `:VimwikiPrevLink` can call it.
|
||||
local function jump_link(direction)
|
||||
local flags = direction > 0 and 'W' or 'bW'
|
||||
local pos = vim.fn.searchpos([[\[\[]], flags)
|
||||
if pos[1] > 0 then
|
||||
vim.api.nvim_win_set_cursor(0, { pos[1], pos[2] - 1 })
|
||||
end
|
||||
end
|
||||
function M.link_next() jump_link(1) end
|
||||
function M.link_prev() jump_link(-1) end
|
||||
|
||||
--- `:VimwikiBaddLink` — add the link target's file to the buffer list
|
||||
--- (`:badd`) without switching to it. Uses the LSP definition response
|
||||
--- to find the file.
|
||||
function M.badd_link()
|
||||
local client = find_client()
|
||||
if not client then
|
||||
vim.notify('nuwiki: language server not attached', vim.log.levels.ERROR)
|
||||
return
|
||||
end
|
||||
local params = position_params(client)
|
||||
params.textDocument = { uri = buf_uri() }
|
||||
local req = client.request
|
||||
local handler = function(_, result)
|
||||
local loc
|
||||
if type(result) == 'table' then
|
||||
loc = result[1] or result
|
||||
end
|
||||
local uri = type(loc) == 'table' and (loc.uri or loc.targetUri) or nil
|
||||
if not uri then return end
|
||||
local path = vim.uri_to_fname(uri)
|
||||
pcall(vim.cmd, 'badd ' .. vim.fn.fnameescape(path))
|
||||
vim.notify('nuwiki: added ' .. path, vim.log.levels.INFO)
|
||||
end
|
||||
local bufnr = vim.api.nvim_get_current_buf()
|
||||
if type(req) == 'function' then
|
||||
client:request('textDocument/definition', params, handler, bufnr)
|
||||
end
|
||||
end
|
||||
|
||||
function M.follow_link_or_create()
|
||||
if cursor_inside_wikilink() then
|
||||
vim.lsp.buf.definition()
|
||||
|
||||
+22
-1
@@ -6,11 +6,31 @@
|
||||
local M = {}
|
||||
|
||||
M.defaults = {
|
||||
-- v1.0 single-wiki shorthand. The server desugars these into a
|
||||
-- one-entry `wikis = {…}` list. Use either this or the multi-wiki
|
||||
-- form below; if both are set, `wikis` wins.
|
||||
wiki_root = '~/vimwiki',
|
||||
file_extension = '.wiki',
|
||||
syntax = 'vimwiki',
|
||||
log_level = 'warn',
|
||||
|
||||
-- v1.1 multi-wiki shape. Each entry honours every per-wiki key the
|
||||
-- server understands. Leave nil to fall through to the shorthand
|
||||
-- above.
|
||||
--
|
||||
-- Per-wiki keys (all optional, server defaults documented in SPEC
|
||||
-- §12.11):
|
||||
-- name, root, file_extension, syntax, index
|
||||
-- diary_rel_path, diary_index, diary_frequency,
|
||||
-- diary_start_week_day, diary_caption_level, diary_sort,
|
||||
-- diary_header
|
||||
-- html_path, template_path, template_default, template_ext,
|
||||
-- template_date_format, css_name, auto_export, auto_toc,
|
||||
-- html_filename_parameterization, exclude_files, color_dic
|
||||
-- maxhi, listsyms, listsyms_propagate, list_margin,
|
||||
-- links_space_char, nested_syntaxes
|
||||
wikis = nil,
|
||||
|
||||
-- Phase 19: per-buffer glue. Each subgroup mirrors vimwiki's
|
||||
-- `g:vimwiki_key_mappings` shape and can be flipped off
|
||||
-- independently to suppress that group of keymaps.
|
||||
@@ -23,7 +43,8 @@ M.defaults = {
|
||||
table_editing = true, -- gqq, gq1, gww, gw1, <A-Left>, <A-Right>
|
||||
diary = true, -- <C-Down>, <C-Up>
|
||||
html_export = true, -- <Leader>wh, <Leader>whh
|
||||
text_objects = true, -- ah, ih, …
|
||||
text_objects = true, -- ah, ih, aH, iH, al, il, a\, i\, ac, ic
|
||||
mouse = false, -- <2-LeftMouse>, <RightMouse>, … (opt-in)
|
||||
},
|
||||
|
||||
-- Phase 19: folding. `'lsp'` uses the server's `foldingRange`
|
||||
|
||||
@@ -271,6 +271,23 @@ function M.attach(bufnr, mappings)
|
||||
map('n', '<Leader>whh', cmd.export_browse, { desc = 'nuwiki: export + browse' }, bufnr)
|
||||
map('n', '<Leader>wha', cmd.export_all, { desc = 'nuwiki: export all' }, bufnr)
|
||||
end
|
||||
|
||||
-- ===== Mouse (opt-in via `mappings.mouse = true`) =====
|
||||
-- Mirrors upstream vimwiki: double-click follows, shift/ctrl-double
|
||||
-- open in split/vsplit, middle-click adds the linked file to the
|
||||
-- buffer list, right-click + left-click goes back.
|
||||
if mappings.mouse then
|
||||
map('n', '<2-LeftMouse>', cmd.follow_link_or_create,
|
||||
{ desc = 'nuwiki: follow link (mouse)' }, bufnr)
|
||||
map('n', '<S-2-LeftMouse>', function() vim.cmd('split'); cmd.follow_link_or_create() end,
|
||||
{ desc = 'nuwiki: follow link in split (mouse)' }, bufnr)
|
||||
map('n', '<C-2-LeftMouse>', function() vim.cmd('vsplit'); cmd.follow_link_or_create() end,
|
||||
{ desc = 'nuwiki: follow link in vsplit (mouse)' }, bufnr)
|
||||
map('n', '<MiddleMouse>', cmd.badd_link,
|
||||
{ desc = 'nuwiki: badd link target' }, bufnr)
|
||||
map('n', '<RightMouse>', '<C-o>',
|
||||
{ desc = 'nuwiki: go back (mouse)', remap = true }, bufnr)
|
||||
end
|
||||
end
|
||||
|
||||
return M
|
||||
|
||||
Reference in New Issue
Block a user