diff --git a/autoload/nuwiki/commands.vim b/autoload/nuwiki/commands.vim index c1429da..a32f56e 100644 --- a/autoload/nuwiki/commands.vim +++ b/autoload/nuwiki/commands.vim @@ -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 : follow or wrap-then-follow ===== function! s:cursor_inside_wikilink() abort diff --git a/crates/nuwiki-core/src/render/html.rs b/crates/nuwiki-core/src/render/html.rs index b54c756..54c648b 100644 --- a/crates/nuwiki-core/src/render/html.rs +++ b/crates/nuwiki-core/src/render/html.rs @@ -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"\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"\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"\n")?; @@ -315,15 +321,29 @@ impl HtmlRenderer { w.write_all(b"
\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], + w: &mut dyn Write, + ) -> io::Result<()> { w.write_all(b"")?; - 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"\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, "") } @@ -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> { + 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> = (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: diff --git a/crates/nuwiki-lsp/src/commands.rs b/crates/nuwiki-lsp/src/commands.rs index 8db62bc..584880a 100644 --- a/crates/nuwiki-lsp/src/commands.rs +++ b/crates/nuwiki-lsp/src/commands.rs @@ -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); }; diff --git a/crates/nuwiki-lsp/src/config.rs b/crates/nuwiki-lsp/src/config.rs index aedfa09..c08237d 100644 --- a/crates/nuwiki-lsp/src/config.rs +++ b/crates/nuwiki-lsp/src/config.rs @@ -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"`). `/` 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 `//.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, + /// 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` 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, + 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>, #[serde(default)] color_dic: Option>, + // v1.1 per-wiki keys mirroring vimwiki globals. All optional so + // existing single-wiki configs migrate without touching anything. + #[serde(default)] + index: Option, + #[serde(default)] + diary_frequency: Option, + #[serde(default)] + diary_start_week_day: Option, + #[serde(default)] + diary_caption_level: Option, + #[serde(default)] + diary_sort: Option, + #[serde(default)] + diary_header: Option, + #[serde(default)] + maxhi: Option, + #[serde(default)] + listsyms: Option, + #[serde(default)] + listsyms_propagate: Option, + #[serde(default)] + list_margin: Option, + #[serde(default)] + links_space_char: Option, + #[serde(default)] + nested_syntaxes: Option>, + #[serde(default)] + auto_toc: Option, } impl From for WikiConfig { @@ -329,13 +489,27 @@ impl From 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, } } diff --git a/crates/nuwiki-lsp/tests/parity_cluster_1.rs b/crates/nuwiki-lsp/tests/parity_cluster_1.rs new file mode 100644 index 0000000..1a3beff --- /dev/null +++ b/crates/nuwiki-lsp/tests/parity_cluster_1.rs @@ -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>, has_header: bool) -> DocumentNode { + let span = Span::default(); + let rows: Vec = 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#"d"#), "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#"b"#), "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")); +} diff --git a/ftplugin/vimwiki.vim b/ftplugin/vimwiki.vim index ec8f0f0..48b0f36 100644 --- a/ftplugin/vimwiki.vim +++ b/ftplugin/vimwiki.vim @@ -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 // ** command! -buffer -nargs=1 VWS lvimgrep // ** command! -buffer -nargs=1 VimwikiGoto call nuwiki#commands#wiki_goto_page() @@ -181,6 +184,15 @@ if !has('nvim') nnoremap whh :call nuwiki#commands#export_browse() nnoremap wha :call nuwiki#commands#export_all() + " Mouse (opt-in via g:nuwiki_mouse_mappings = 1). + if get(g:, 'nuwiki_mouse_mappings', 0) + nnoremap <2-LeftMouse> :call nuwiki#commands#follow_link_or_create() + nnoremap :split:call nuwiki#commands#follow_link_or_create() + nnoremap :vsplit:call nuwiki#commands#follow_link_or_create() + nnoremap :call nuwiki#commands#badd_link() + nnoremap + endif + " Tables nnoremap gqq :call nuwiki#commands#table_align() nnoremap gq1 :call nuwiki#commands#table_align() @@ -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() command! -buffer VimwikiBacklinks lua vim.lsp.buf.references() command! -buffer VWB lua vim.lsp.buf.references() diff --git a/lua/nuwiki/commands.lua b/lua/nuwiki/commands.lua index aa65688..4e9c321 100644 --- a/lua/nuwiki/commands.lua +++ b/lua/nuwiki/commands.lua @@ -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() diff --git a/lua/nuwiki/config.lua b/lua/nuwiki/config.lua index 8485648..0f1404c 100644 --- a/lua/nuwiki/config.lua +++ b/lua/nuwiki/config.lua @@ -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, , diary = true, -- , html_export = true, -- wh, whh - text_objects = true, -- ah, ih, … + text_objects = true, -- ah, ih, aH, iH, al, il, a\, i\, ac, ic + mouse = false, -- <2-LeftMouse>, , … (opt-in) }, -- Phase 19: folding. `'lsp'` uses the server's `foldingRange` diff --git a/lua/nuwiki/keymaps.lua b/lua/nuwiki/keymaps.lua index e22f651..5b2ee63 100644 --- a/lua/nuwiki/keymaps.lua +++ b/lua/nuwiki/keymaps.lua @@ -271,6 +271,23 @@ function M.attach(bufnr, mappings) map('n', 'whh', cmd.export_browse, { desc = 'nuwiki: export + browse' }, bufnr) map('n', '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', '', function() vim.cmd('split'); cmd.follow_link_or_create() end, + { desc = 'nuwiki: follow link in split (mouse)' }, bufnr) + map('n', '', function() vim.cmd('vsplit'); cmd.follow_link_or_create() end, + { desc = 'nuwiki: follow link in vsplit (mouse)' }, bufnr) + map('n', '', cmd.badd_link, + { desc = 'nuwiki: badd link target' }, bufnr) + map('n', '', '', + { desc = 'nuwiki: go back (mouse)', remap = true }, bufnr) + end end return M