diff --git a/autoload/nuwiki/commands.vim b/autoload/nuwiki/commands.vim index 823918a..f1efa8a 100644 --- a/autoload/nuwiki/commands.vim +++ b/autoload/nuwiki/commands.vim @@ -168,6 +168,28 @@ endfunction " from the server, so the picker works from any buffer before a wiki file is " ever opened — opening the chosen index then auto-starts the LSP via the " 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 +" first configured wiki as a fallback. Used by auto_chdir + search scoping. +function! nuwiki#commands#wiki_root_for(file) abort + for l:w in nuwiki#commands#wiki_list() + let l:wroot = fnamemodify(expand(l:w.root), ':p') + if a:file[: len(l:wroot) - 1] ==# l:wroot + return l:wroot + endif + endfor + let l:wikis = nuwiki#commands#wiki_list() + return empty(l:wikis) ? '' : fnamemodify(expand(l:wikis[0].root), ':p') +endfunction + +" :NuwikiAutoChdir hook — `:lcd` into the owning wiki's root (vimwiki +" `auto_chdir`). Wired from a buffer-local autocmd in the ftplugin. +function! nuwiki#commands#auto_chdir() abort + let l:root = nuwiki#commands#wiki_root_for(expand('%:p')) + if l:root !=# '' + execute 'lcd ' . fnameescape(l:root) + endif +endfunction + " :VimwikiShowVersion / :NuwikiShowVersion — echo the nuwiki version and the " host editor, mirroring upstream's version banner. function! nuwiki#commands#show_version() abort diff --git a/crates/nuwiki-lsp/src/commands.rs b/crates/nuwiki-lsp/src/commands.rs index 5c22f48..3663335 100644 --- a/crates/nuwiki-lsp/src/commands.rs +++ b/crates/nuwiki-lsp/src/commands.rs @@ -338,8 +338,16 @@ fn toc_generate(backend: &Backend, args: Vec) -> Result) -> Result return Ok(None), }; let current_page = crate::index::page_name_from_uri(&p.uri, Some(&wiki.config.root)); - let pages = { + let (pages, captions) = { let idx = wiki .index .read() .map_err(|_| "index lock poisoned".to_string())?; - idx.page_names() + let captions = wiki + .config + .generated_links_caption + .then(|| ops::page_captions(&idx)); + (idx.page_names(), captions) }; Ok(ops::links_edit( &doc.text, @@ -372,6 +384,7 @@ fn links_generate(backend: &Backend, args: Vec) -> Result) -> Result) -> Result) -> Result String { let mut out = caption_line(heading_name, level); if items.is_empty() { @@ -1845,23 +1868,47 @@ pub mod ops { for _ in 0..depth { out.push_str(" "); } - out.push_str("- [[#"); - out.push_str(anchor); - out.push('|'); - out.push_str(title); - out.push_str("]]\n"); + // vimwiki `toc_link_format`: 0 = `[[#anchor|title]]` (with + // description), 1 = `[[#anchor]]` (anchor only). + if link_format == 1 { + out.push_str("- [[#"); + out.push_str(anchor); + out.push_str("]]\n"); + } else { + out.push_str("- [[#"); + out.push_str(anchor); + out.push('|'); + out.push_str(title); + out.push_str("]]\n"); + } } out } /// Build a flat list of wikilinks to every page (sorted), optionally /// excluding `current_page`. + /// Map each indexed page name → its first heading's title (vimwiki's + /// caption for `generated_links_caption`). Pages with no heading are + /// omitted, so `build_links_text` falls back to `[[page]]` for them. + pub fn page_captions( + index: &crate::index::WorkspaceIndex, + ) -> std::collections::BTreeMap { + let mut map = std::collections::BTreeMap::new(); + for name in index.page_names() { + if let Some(h) = index.page_by_name(&name).and_then(|p| p.headings.first()) { + map.insert(name, h.title.clone()); + } + } + map + } + pub fn build_links_text( pages: &[String], heading_name: &str, level: u8, exclude: Option<&str>, margin: usize, + captions: Option<&std::collections::BTreeMap>, ) -> String { let mut out = caption_line(heading_name, level); let pad = " ".repeat(margin); @@ -1872,6 +1919,12 @@ pub mod ops { out.push_str(&pad); out.push_str("- [["); out.push_str(name); + // vimwiki `generated_links_caption`: when a caption map is supplied + // and the page has a first-heading, emit `[[page|Heading]]`. + if let Some(cap) = captions.and_then(|c| c.get(name)).filter(|c| !c.is_empty()) { + out.push('|'); + out.push_str(cap); + } out.push_str("]]\n"); } out @@ -1998,6 +2051,7 @@ pub mod ops { heading: &str, level: u8, margin: usize, + link_format: u8, ) -> Option { let items = collect_toc_items(ast, heading); if items.is_empty() { @@ -2007,7 +2061,7 @@ pub mod ops { .into_iter() .map(|it| (it.level, it.title, it.anchor)) .collect(); - let new_text = build_toc_text(&triples, heading, level, margin); + let new_text = build_toc_text(&triples, heading, level, margin, link_format); let mut b = WorkspaceEditBuilder::new(); match find_section_range(ast, heading) { Some((start, end)) => { @@ -2044,9 +2098,10 @@ pub mod ops { heading: &str, level: u8, margin: usize, + link_format: u8, ) -> Option { find_section_range(ast, heading)?; - toc_edit(text, ast, uri, utf8, heading, level, margin) + toc_edit(text, ast, uri, utf8, heading, level, margin, link_format) } /// Produce a `WorkspaceEdit` that replaces or inserts the @@ -2062,11 +2117,19 @@ pub mod ops { heading: &str, level: u8, margin: usize, + captions: Option<&std::collections::BTreeMap>, ) -> Option { if all_pages.is_empty() { return None; } - let new_text = build_links_text(all_pages, heading, level, Some(current_page), margin); + let new_text = build_links_text( + all_pages, + heading, + level, + Some(current_page), + margin, + captions, + ); let mut b = WorkspaceEditBuilder::new(); match find_section_range(ast, heading) { Some((start, end)) => { @@ -2096,6 +2159,7 @@ pub mod ops { heading: &str, level: u8, margin: usize, + captions: Option<&std::collections::BTreeMap>, ) -> Option { find_section_range(ast, heading)?; links_edit( @@ -2108,6 +2172,7 @@ pub mod ops { heading, level, margin, + captions, ) } @@ -2863,9 +2928,10 @@ pub mod ops { uri: &Url, line: u32, utf8: bool, + reduce_last_col: bool, ) -> Option { let table = find_table_at_line(ast, line)?; - let rendered = render_aligned_table(table, text)?; + let rendered = render_aligned_table(table, text, reduce_last_col)?; let span = table.span; let mut b = WorkspaceEditBuilder::new(); b.edit(uri.clone(), text_edit_replace(span, rendered, text, utf8)); @@ -2874,6 +2940,7 @@ pub mod ops { /// Swap the column under cursor with its left (`delta = -1`) or /// right (`delta = +1`) neighbour across every row of the table. + #[allow(clippy::too_many_arguments)] pub fn table_move_column_edit( text: &str, ast: &DocumentNode, @@ -2882,6 +2949,7 @@ pub mod ops { col: u32, delta: i32, utf8: bool, + reduce_last_col: bool, ) -> Option { let table = find_table_at_line(ast, line)?; let cur_col = cell_column_at(table, text, line, col)?; @@ -2895,7 +2963,7 @@ pub mod ops { if other >= max_cols { return None; } - let rendered = render_swapped_table(table, text, cur_col, other)?; + let rendered = render_swapped_table(table, text, cur_col, other, reduce_last_col)?; let mut b = WorkspaceEditBuilder::new(); b.edit( uri.clone(), @@ -2966,7 +3034,7 @@ pub mod ops { } /// Compute column widths (max content length across rows). - fn column_widths(table: &TableNode, text: &str) -> Vec { + fn column_widths(table: &TableNode, text: &str, reduce_last_col: bool) -> Vec { let max_cols = table.rows.iter().map(|r| r.cells.len()).max().unwrap_or(0); let mut widths = vec![1usize; max_cols]; for row in &table.rows { @@ -2977,6 +3045,13 @@ pub mod ops { } } } + // vimwiki `table_reduce_last_col`: keep the last column at its minimum + // so it isn't padded out to fill. + if reduce_last_col { + if let Some(last) = widths.last_mut() { + *last = 1; + } + } widths } @@ -2987,8 +3062,12 @@ pub mod ops { .all(|c| c == '-' || c == ':' || c.is_whitespace()) } - fn render_aligned_table(table: &TableNode, text: &str) -> Option { - let widths = column_widths(table, text); + fn render_aligned_table( + table: &TableNode, + text: &str, + reduce_last_col: bool, + ) -> Option { + let widths = column_widths(table, text, reduce_last_col); let mut out = String::new(); for (i, row) in table.rows.iter().enumerate() { render_row(row, text, &widths, &mut out); @@ -3037,10 +3116,16 @@ pub mod ops { out.push('\n'); } - fn render_swapped_table(table: &TableNode, text: &str, a: usize, b: usize) -> Option { + fn render_swapped_table( + table: &TableNode, + text: &str, + a: usize, + b: usize, + reduce_last_col: bool, + ) -> Option { // Swap the column-width slots first so the rendered table // remains visually aligned after the move. - let mut widths = column_widths(table, text); + let mut widths = column_widths(table, text, reduce_last_col); if a < widths.len() && b < widths.len() { widths.swap(a, b); } diff --git a/crates/nuwiki-lsp/src/config.rs b/crates/nuwiki-lsp/src/config.rs index fdf7a93..16d4918 100644 --- a/crates/nuwiki-lsp/src/config.rs +++ b/crates/nuwiki-lsp/src/config.rs @@ -108,6 +108,28 @@ pub struct WikiConfig { /// (`tags_header` / `tags_header_level`). pub tags_header: String, pub tags_header_level: u8, + /// vimwiki `create_link` (default `true`): when following a link to a + /// missing page, create it. When `false`, missing-link follow is a no-op. + pub create_link: bool, + /// vimwiki `dir_link`: index file opened when following a link to a + /// directory (e.g. `index` → `dir/index.wiki`). Empty = open the directory. + pub dir_link: String, + /// vimwiki `bullet_types`: ordered list of unordered-bullet glyphs for + /// this wiki's syntax (default `-`, `*`, `#`); drives `cycle_bullets`. + pub bullet_types: Vec, + /// vimwiki `cycle_bullets` (default `false`): rotate an unordered item's + /// glyph through `bullet_types` as it is indented/dedented. + pub cycle_bullets: bool, + /// vimwiki `generated_links_caption` (default `false`): emit + /// `[[page|Heading]]` (first heading as caption) from `:VimwikiGenerateLinks`. + pub generated_links_caption: bool, + /// vimwiki `toc_link_format` (default `0`): `0` = `[[#anchor|title]]` + /// (leaf anchor, with description); `1` = `[[#parent#child]]` (full path, + /// no description). + pub toc_link_format: u8, + /// vimwiki `table_reduce_last_col` (default `false`): don't pad the last + /// table column to fill — keep it at its minimum width. + pub table_reduce_last_col: bool, /// HTML export options. pub html: HtmlConfig, } @@ -155,6 +177,32 @@ pub struct HtmlConfig { /// vimwiki `html_header_numbering_sym`: symbol appended after the /// section number (e.g. `.` → `1.2. Heading`). Empty by default. pub html_header_numbering_sym: String, + /// vimwiki `rss_name`: filename of the generated RSS feed (default + /// `rss.xml`), relative to `html_path`. + pub rss_name: String, + /// vimwiki `rss_max_items`: cap on the number of diary items in the RSS + /// feed (default `10`; `0` = unlimited-but-empty per upstream's min). + pub rss_max_items: usize, + /// vimwiki `valid_html_tags`: inline HTML tag names that pass through to + /// HTML export unescaped (everything else has `<`/`>` escaped). Default + /// `b,i,s,u,sub,sup,kbd,br,hr,div,center,strong,em`. + pub valid_html_tags: Vec, + /// vimwiki `list_ignore_newline` (default `true`): a single newline inside + /// a list item is a space in HTML export; when `false` it becomes `
`. + pub list_ignore_newline: bool, + /// vimwiki `text_ignore_newline` (default `true`): a single newline inside + /// a paragraph is a space in HTML export; when `false` it becomes `
`. + pub text_ignore_newline: bool, + /// vimwiki `emoji_enable` (default `true`): substitute `:alias:` emoji + /// shortcodes with their glyph during HTML export. + pub emoji_enable: bool, + /// vimwiki `user_htmls`: basenames of HTML files with no wiki source that + /// `allToHtml` must not prune. Empty by default. + pub user_htmls: Vec, + /// vimwiki `color_tag_template`: HTML template for a colour span. The + /// `__COLORFG__` / `__COLORBG__` / `__CONTENT__` placeholders are filled + /// from `color_dic`. Default matches upstream's `` template. + pub color_tag_template: String, } impl Default for HtmlConfig { @@ -169,16 +217,56 @@ impl Default for HtmlConfig { auto_export: false, html_filename_parameterization: false, exclude_files: Vec::new(), - color_dic: std::collections::HashMap::new(), + color_dic: default_color_dic(), custom_wiki2html: String::new(), custom_wiki2html_args: String::new(), base_url: String::new(), html_header_numbering: 0, html_header_numbering_sym: String::new(), + rss_name: "rss.xml".into(), + rss_max_items: 10, + valid_html_tags: default_valid_html_tags(), + list_ignore_newline: true, + text_ignore_newline: true, + emoji_enable: true, + user_htmls: Vec::new(), + color_tag_template: default_color_tag_template(), } } } +/// vimwiki's default `valid_html_tags` — inline tags allowed through export. +fn default_valid_html_tags() -> Vec { + "b,i,s,u,sub,sup,kbd,br,hr,div,center,strong,em" + .split(',') + .map(|s| s.to_string()) + .collect() +} + +/// vimwiki's default `color_tag_template`. +fn default_color_tag_template() -> String { + "__CONTENT__".to_string() +} + +/// A populated default `color_dic` (vimwiki ships a palette rather than the +/// empty map nuwiki previously defaulted to). Names → CSS colour value. +fn default_color_dic() -> std::collections::HashMap { + [ + ("default", "inherit"), + ("red", "red"), + ("green", "green"), + ("blue", "blue"), + ("yellow", "#b8860b"), + ("magenta", "magenta"), + ("cyan", "darkcyan"), + ("gray", "gray"), + ("grey", "gray"), + ] + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect() +} + impl HtmlConfig { /// Synthesise default paths under `root` when the user hasn't /// specified explicit ones. `html_path` defaults to `/_html` @@ -257,6 +345,13 @@ impl WikiConfig { links_header_level: d.links_header_level, tags_header: d.tags_header, tags_header_level: d.tags_header_level, + create_link: d.create_link, + dir_link: d.dir_link, + bullet_types: d.bullet_types, + cycle_bullets: d.cycle_bullets, + generated_links_caption: d.generated_links_caption, + toc_link_format: d.toc_link_format, + table_reduce_last_col: d.table_reduce_last_col, html: HtmlConfig::default(), } } @@ -297,6 +392,13 @@ impl WikiConfig { links_header_level: d.links_header_level, tags_header: d.tags_header, tags_header_level: d.tags_header_level, + create_link: d.create_link, + dir_link: d.dir_link, + bullet_types: d.bullet_types, + cycle_bullets: d.cycle_bullets, + generated_links_caption: d.generated_links_caption, + toc_link_format: d.toc_link_format, + table_reduce_last_col: d.table_reduce_last_col, html, } } @@ -462,9 +564,21 @@ fn wiki_defaults() -> WikiDefaults { links_header_level: 1, tags_header: default_tags_header(), tags_header_level: 1, + create_link: true, + dir_link: String::new(), + bullet_types: default_bullet_types(), + cycle_bullets: false, + generated_links_caption: false, + toc_link_format: 0, + table_reduce_last_col: false, } } +/// vimwiki's default `bullet_types` for the `vimwiki` syntax. +fn default_bullet_types() -> Vec { + ["-", "*", "#"].iter().map(|s| s.to_string()).collect() +} + struct WikiDefaults { index: String, diary_rel_path: String, @@ -491,6 +605,13 @@ struct WikiDefaults { links_header_level: u8, tags_header: String, tags_header_level: u8, + create_link: bool, + dir_link: String, + bullet_types: Vec, + cycle_bullets: bool, + generated_links_caption: bool, + toc_link_format: u8, + table_reduce_last_col: bool, } impl Config { @@ -699,6 +820,22 @@ struct RawWiki { html_header_numbering: Option, #[serde(default)] html_header_numbering_sym: Option, + #[serde(default)] + rss_name: Option, + #[serde(default)] + rss_max_items: Option, + #[serde(default)] + valid_html_tags: Option, + #[serde(default, deserialize_with = "opt_bool_or_int::deserialize")] + list_ignore_newline: Option, + #[serde(default, deserialize_with = "opt_bool_or_int::deserialize")] + text_ignore_newline: Option, + #[serde(default, deserialize_with = "opt_bool_or_int::deserialize")] + emoji_enable: Option, + #[serde(default)] + user_htmls: Option>, + #[serde(default)] + color_tag_template: Option, // per-wiki keys mirroring vimwiki globals. All optional so // existing single-wiki configs migrate without touching anything. #[serde(default)] @@ -747,6 +884,20 @@ struct RawWiki { tags_header: Option, #[serde(default)] tags_header_level: Option, + #[serde(default, deserialize_with = "opt_bool_or_int::deserialize")] + create_link: Option, + #[serde(default)] + dir_link: Option, + #[serde(default)] + bullet_types: Option>, + #[serde(default, deserialize_with = "opt_bool_or_int::deserialize")] + cycle_bullets: Option, + #[serde(default, deserialize_with = "opt_bool_or_int::deserialize")] + generated_links_caption: Option, + #[serde(default)] + toc_link_format: Option, + #[serde(default, deserialize_with = "opt_bool_or_int::deserialize")] + table_reduce_last_col: Option, } impl From for WikiConfig { @@ -802,6 +953,30 @@ impl From for WikiConfig { if let Some(s) = r.html_header_numbering_sym { html.html_header_numbering_sym = s; } + if let Some(s) = r.rss_name { + html.rss_name = s; + } + if let Some(n) = r.rss_max_items { + html.rss_max_items = n; + } + if let Some(s) = r.valid_html_tags { + html.valid_html_tags = s.split(',').map(|t| t.trim().to_string()).collect(); + } + if let Some(b) = r.list_ignore_newline { + html.list_ignore_newline = b; + } + if let Some(b) = r.text_ignore_newline { + html.text_ignore_newline = b; + } + if let Some(b) = r.emoji_enable { + html.emoji_enable = b; + } + if let Some(v) = r.user_htmls { + html.user_htmls = v; + } + if let Some(s) = r.color_tag_template { + html.color_tag_template = s; + } let d = wiki_defaults(); let syntax = r.syntax.unwrap_or_else(|| "vimwiki".into()); // vimwiki derives `list_margin = 0` for markdown wikis when the key is @@ -841,6 +1016,18 @@ impl From for WikiConfig { links_header_level: r.links_header_level.unwrap_or(d.links_header_level), tags_header: r.tags_header.unwrap_or(d.tags_header), tags_header_level: r.tags_header_level.unwrap_or(d.tags_header_level), + create_link: r.create_link.unwrap_or(d.create_link), + dir_link: r.dir_link.unwrap_or(d.dir_link), + bullet_types: r + .bullet_types + .filter(|v| !v.is_empty()) + .unwrap_or(d.bullet_types), + cycle_bullets: r.cycle_bullets.unwrap_or(d.cycle_bullets), + generated_links_caption: r + .generated_links_caption + .unwrap_or(d.generated_links_caption), + toc_link_format: r.toc_link_format.unwrap_or(d.toc_link_format), + table_reduce_last_col: r.table_reduce_last_col.unwrap_or(d.table_reduce_last_col), html, } } diff --git a/crates/nuwiki-lsp/src/lib.rs b/crates/nuwiki-lsp/src/lib.rs index e715ec2..931cec9 100644 --- a/crates/nuwiki-lsp/src/lib.rs +++ b/crates/nuwiki-lsp/src/lib.rs @@ -628,6 +628,7 @@ impl LanguageServer for Backend { &wiki.config.toc_header, wiki.config.toc_header_level, wiki.config.list_margin.max(0) as usize, + wiki.config.toc_link_format, ) }) }; @@ -642,11 +643,17 @@ impl LanguageServer for Backend { if wiki.config.auto_generate_links { let edit = self.documents.get(&uri).and_then(|doc| { let current = index::page_name_from_uri(&uri, Some(&wiki.config.root)); - let pages = wiki + let (pages, captions) = wiki .index .read() .ok() - .map(|i| i.page_names()) + .map(|i| { + let caps = wiki + .config + .generated_links_caption + .then(|| commands::ops::page_captions(&i)); + (i.page_names(), caps) + }) .unwrap_or_default(); commands::ops::links_rebuild_edit( &doc.text, @@ -658,6 +665,7 @@ impl LanguageServer for Backend { &wiki.config.links_header, wiki.config.links_header_level, wiki.config.list_margin.max(0) as usize, + captions.as_ref(), ) }); if let Some(edit) = edit { diff --git a/crates/nuwiki-lsp/tests/commands_coverage.rs b/crates/nuwiki-lsp/tests/commands_coverage.rs index 8201e7b..6275fe9 100644 --- a/crates/nuwiki-lsp/tests/commands_coverage.rs +++ b/crates/nuwiki-lsp/tests/commands_coverage.rs @@ -306,7 +306,8 @@ fn nuwiki_toc_generates_nested_contents() { let src = "= Top =\n== Sub ==\ntext\n"; let doc = parse(src); let uri = Url::from_file_path("/wiki/Page.wiki").unwrap(); - let edit = ops::toc_edit(src, &doc, &uri, true, "Contents", 1, 0).expect("toc edit produced"); + let edit = + ops::toc_edit(src, &doc, &uri, true, "Contents", 1, 0, 0).expect("toc edit produced"); assert!(edit.changes.is_some() || edit.document_changes.is_some()); let toc = ops::build_toc_text( @@ -317,6 +318,7 @@ fn nuwiki_toc_generates_nested_contents() { "Contents", 1, 0, + 0, ); assert!(toc.contains("= Contents =")); assert!(toc.contains("- [[#top|Top]]")); @@ -327,7 +329,7 @@ fn nuwiki_toc_generates_nested_contents() { #[test] fn nuwiki_generate_links_lists_all_pages_excluding_current() { let pages = vec!["About".to_string(), "Home".to_string(), "Notes".to_string()]; - let text = ops::build_links_text(&pages, "Links", 1, Some("Home"), 0); + let text = ops::build_links_text(&pages, "Links", 1, Some("Home"), 0, None); assert!(text.contains("= Links =")); assert!(text.contains("- [[About]]")); assert!(text.contains("- [[Notes]]")); @@ -345,7 +347,8 @@ fn nuwiki_generate_links_lists_all_pages_excluding_current() { true, "Generated Links", 1, - 0 + 0, + None ) .is_some()); } diff --git a/crates/nuwiki-lsp/tests/commands_tables.rs b/crates/nuwiki-lsp/tests/commands_tables.rs index 4ac4e40..6375f12 100644 --- a/crates/nuwiki-lsp/tests/commands_tables.rs +++ b/crates/nuwiki-lsp/tests/commands_tables.rs @@ -45,7 +45,7 @@ fn blank_table_handles_single_cell_grid() { fn align_pads_short_cells_to_widest() { let src = "|a|bbb|c|\n|--|--|--|\n|1|2|three|\n"; let ast = parse(src); - let edit = ops::table_align_edit(src, &ast, &uri(), 0, true).expect("edit"); + let edit = ops::table_align_edit(src, &ast, &uri(), 0, true, false).expect("edit"); let body = one_text(edit); let lines: Vec<&str> = body.lines().collect(); eprintln!("rendered table:"); @@ -64,7 +64,23 @@ fn align_pads_short_cells_to_widest() { fn align_returns_none_when_cursor_off_table() { let src = "paragraph\n"; let ast = parse(src); - assert!(ops::table_align_edit(src, &ast, &uri(), 0, true).is_none()); + assert!(ops::table_align_edit(src, &ast, &uri(), 0, true, false).is_none()); +} + +#[test] +fn table_reduce_last_col_keeps_last_column_minimal() { + // With reduce_last_col=true, the last column isn't padded to its content + // width — it stays at the minimum (1), so `three` is not surrounded by + // alignment padding on the right edge. + let src = "|a|bbb|c|\n|--|--|--|\n|1|2|three|\n"; + let ast = parse(src); + let edit = ops::table_align_edit(src, &ast, &uri(), 0, true, true).expect("edit"); + let body = one_text(edit); + let lines: Vec<&str> = body.lines().collect(); + // First two columns still align; the last column stays at width 1 (not 5), + // so the header's last cell renders `| c |` not `| c |`. + assert_eq!(lines[0], "| a | bbb | c |", "got: {:?}", lines[0]); + assert_eq!(lines[2], "| 1 | 2 | three |", "got: {:?}", lines[2]); } // ===== moveColumn ===== @@ -74,7 +90,7 @@ fn move_column_right_swaps_with_neighbour() { let src = "|a|b|c|\n|--|--|--|\n|1|2|3|\n"; let ast = parse(src); // Cursor on first column → swap right. - let edit = ops::table_move_column_edit(src, &ast, &uri(), 0, 1, 1, true).expect("edit"); + let edit = ops::table_move_column_edit(src, &ast, &uri(), 0, 1, 1, true, false).expect("edit"); let body = one_text(edit); let lines: Vec<&str> = body.lines().collect(); assert!(lines[0].contains("b") && lines[0].contains("a")); @@ -90,7 +106,7 @@ fn move_column_left_at_zero_is_noop() { let src = "|a|b|\n|--|--|\n|1|2|\n"; let ast = parse(src); // Cursor in column 0; left would be -1 → returns None. - let edit = ops::table_move_column_edit(src, &ast, &uri(), 0, 1, -1, true); + let edit = ops::table_move_column_edit(src, &ast, &uri(), 0, 1, -1, true, false); assert!(edit.is_none()); } @@ -98,7 +114,7 @@ fn move_column_left_at_zero_is_noop() { fn move_column_returns_none_off_table() { let src = "paragraph\n"; let ast = parse(src); - assert!(ops::table_move_column_edit(src, &ast, &uri(), 0, 0, 1, true).is_none()); + assert!(ops::table_move_column_edit(src, &ast, &uri(), 0, 0, 1, true, false).is_none()); } // ===== COMMANDS list ===== diff --git a/crates/nuwiki-lsp/tests/index_and_config.rs b/crates/nuwiki-lsp/tests/index_and_config.rs index 6eb51ac..996dc27 100644 --- a/crates/nuwiki-lsp/tests/index_and_config.rs +++ b/crates/nuwiki-lsp/tests/index_and_config.rs @@ -295,6 +295,63 @@ fn defaults_carry_vimwiki_per_wiki_keys() { // HTML section numbering is off by default (vimwiki's `0`). assert_eq!(cfg.html.html_header_numbering, 0); assert_eq!(cfg.html.html_header_numbering_sym, ""); + // New config defaults match upstream. + assert!(cfg.create_link); + assert_eq!(cfg.dir_link, ""); + assert_eq!(cfg.bullet_types, vec!["-", "*", "#"]); + assert!(!cfg.cycle_bullets); + assert!(!cfg.generated_links_caption); + assert_eq!(cfg.toc_link_format, 0); + assert!(!cfg.table_reduce_last_col); + assert_eq!(cfg.html.rss_name, "rss.xml"); + assert_eq!(cfg.html.rss_max_items, 10); + assert!(cfg.html.list_ignore_newline); + assert!(cfg.html.text_ignore_newline); + assert!(cfg.html.emoji_enable); + assert!(cfg.html.user_htmls.is_empty()); + assert!(cfg.html.valid_html_tags.contains(&"sub".to_string())); + // color_dic ships a populated palette (vimwiki seeds one). + assert!(cfg.html.color_dic.contains_key("red")); +} + +#[test] +fn raw_wiki_parses_config_batch_keys() { + let cfg = config_from_json(serde_json::json!({ + "wikis": [{ + "root": "/tmp/w", + "create_link": false, + "dir_link": "index", + "bullet_types": ["+", "-"], + "cycle_bullets": true, + "generated_links_caption": 1, + "toc_link_format": 1, + "table_reduce_last_col": true, + "rss_name": "feed.xml", + "rss_max_items": 5, + "valid_html_tags": "b,mark", + "list_ignore_newline": 0, + "text_ignore_newline": false, + "emoji_enable": 0, + "user_htmls": ["404.html"], + "color_tag_template": "__CONTENT__", + }], + })); + let w = &cfg.wikis[0]; + assert!(!w.create_link); + assert_eq!(w.dir_link, "index"); + assert_eq!(w.bullet_types, vec!["+", "-"]); + assert!(w.cycle_bullets); + assert!(w.generated_links_caption); + assert_eq!(w.toc_link_format, 1); + assert!(w.table_reduce_last_col); + assert_eq!(w.html.rss_name, "feed.xml"); + assert_eq!(w.html.rss_max_items, 5); + assert_eq!(w.html.valid_html_tags, vec!["b", "mark"]); + assert!(!w.html.list_ignore_newline); + assert!(!w.html.text_ignore_newline); + assert!(!w.html.emoji_enable); + assert_eq!(w.html.user_htmls, vec!["404.html"]); + assert_eq!(w.html.color_tag_template, "__CONTENT__"); } #[test] diff --git a/crates/nuwiki-lsp/tests/link_health.rs b/crates/nuwiki-lsp/tests/link_health.rs index 76738b9..be643c4 100644 --- a/crates/nuwiki-lsp/tests/link_health.rs +++ b/crates/nuwiki-lsp/tests/link_health.rs @@ -290,7 +290,7 @@ fn build_toc_text_nests_by_level() { (2u8, "Sub".into(), "sub".into()), (1u8, "Other".into(), "other".into()), ]; - let out = ops::build_toc_text(&items, "Contents", 1, 0); + let out = ops::build_toc_text(&items, "Contents", 1, 0, 0); assert!(out.starts_with("= Contents =\n")); let lines: Vec<&str> = out.lines().collect(); assert_eq!(lines[0], "= Contents ="); @@ -301,10 +301,36 @@ fn build_toc_text_nests_by_level() { #[test] fn build_toc_text_with_no_headings_is_just_the_heading() { - let out = ops::build_toc_text(&[], "Contents", 1, 0); + let out = ops::build_toc_text(&[], "Contents", 1, 0, 0); assert_eq!(out, "= Contents =\n"); } +#[test] +fn toc_link_format_1_drops_the_description() { + // vimwiki toc_link_format: 1 = `[[#anchor]]` (no `|title`); 0 = with title. + let items = vec![(1u8, "Top".into(), "top".into())]; + let f0 = ops::build_toc_text(&items, "Contents", 1, 0, 0); + assert!(f0.contains("- [[#top|Top]]"), "format 0: {f0}"); + let f1 = ops::build_toc_text(&items, "Contents", 1, 0, 1); + assert!(f1.contains("- [[#top]]"), "format 1: {f1}"); + assert!(!f1.contains("|Top"), "format 1 has no description: {f1}"); +} + +#[test] +fn generated_links_caption_emits_first_heading() { + use std::collections::BTreeMap; + let pages = vec!["About".to_string(), "Notes".to_string()]; + let mut caps = BTreeMap::new(); + caps.insert("About".to_string(), "About Us".to_string()); + // Notes has no caption → falls back to bare [[Notes]]. + let out = ops::build_links_text(&pages, "Generated Links", 1, None, 0, Some(&caps)); + assert!(out.contains("- [[About|About Us]]"), "captioned: {out}"); + assert!(out.contains("- [[Notes]]"), "fallback: {out}"); + // Without the caption map, both are bare. + let bare = ops::build_links_text(&pages, "Generated Links", 1, None, 0, None); + assert!(bare.contains("- [[About]]") && !bare.contains("About Us"), "bare: {bare}"); +} + #[test] fn links_rebuild_edit_only_acts_when_section_present() { let pages = vec!["Home".to_string(), "About".to_string()]; @@ -320,7 +346,8 @@ fn links_rebuild_edit_only_acts_when_section_present() { true, "Generated Links", 1, - 0 + 0, + None ) .is_none(), "must not insert a links section into a page that lacks one" @@ -335,7 +362,8 @@ fn links_rebuild_edit_only_acts_when_section_present() { true, "Generated Links", 1, - 0 + 0, + None ) .is_some()); } @@ -360,15 +388,15 @@ fn find_section_range_stops_at_same_level_sibling() { #[test] fn captions_honour_custom_header_and_level() { // toc_header="Table of Contents", level 2 → `== Table of Contents ==`. - let out = ops::build_toc_text(&[], "Table of Contents", 2, 0); + let out = ops::build_toc_text(&[], "Table of Contents", 2, 0, 0); assert_eq!(out, "== Table of Contents ==\n"); // links_header at level 3. let pages = vec!["A".to_string(), "B".to_string()]; - let links = ops::build_links_text(&pages, "All Pages", 3, None, 0); + let links = ops::build_links_text(&pages, "All Pages", 3, None, 0, None); assert!(links.starts_with("=== All Pages ===\n")); // level clamps to 1..=6. - assert!(ops::build_toc_text(&[], "X", 9, 0).starts_with("====== X ======")); - assert!(ops::build_toc_text(&[], "X", 0, 0).starts_with("= X =")); + assert!(ops::build_toc_text(&[], "X", 9, 0, 0).starts_with("====== X ======")); + assert!(ops::build_toc_text(&[], "X", 0, 0, 0).starts_with("= X =")); } #[test] @@ -377,7 +405,7 @@ fn list_margin_indents_generated_bullets_not_headings() { // The heading stays at column 0; bullets get `margin` spaces (TOC keeps // its per-depth nesting *after* the base margin). let pages = vec!["A".to_string(), "B".to_string()]; - let links = ops::build_links_text(&pages, "Generated Links", 1, None, 2); + let links = ops::build_links_text(&pages, "Generated Links", 1, None, 2, None); assert!(links.contains("\n - [[A]]\n"), "2-space margin: {links:?}"); assert!( links.starts_with("= Generated Links =\n"), @@ -392,6 +420,7 @@ fn list_margin_indents_generated_bullets_not_headings() { "Contents", 1, 2, + 0, ); // Top bullet: 2-space margin; Sub bullet: margin + one nesting level. assert!(toc.contains("\n - [[#top|Top]]\n"), "top margin: {toc:?}"); @@ -403,7 +432,7 @@ fn list_margin_indents_generated_bullets_not_headings() { #[test] fn build_links_text_excludes_current_page() { let pages = vec!["A".to_string(), "Home".to_string(), "B".to_string()]; - let out = ops::build_links_text(&pages, "Generated Links", 1, Some("Home"), 0); + let out = ops::build_links_text(&pages, "Generated Links", 1, Some("Home"), 0, None); let lines: Vec<&str> = out.lines().collect(); assert_eq!(lines[0], "= Generated Links ="); assert!(lines.contains(&"- [[A]]")); @@ -414,7 +443,7 @@ fn build_links_text_excludes_current_page() { #[test] fn build_links_text_no_excludes_includes_all() { let pages = vec!["A".to_string(), "B".to_string()]; - let out = ops::build_links_text(&pages, "Generated Links", 1, None, 0); + let out = ops::build_links_text(&pages, "Generated Links", 1, None, 0, None); assert!(out.contains("[[A]]")); assert!(out.contains("[[B]]")); } @@ -452,7 +481,7 @@ fn toc_edit_inserts_at_top_when_no_existing_toc() { let src = "= One =\n== Two ==\n"; let ast = parse(src); let uri = Url::parse("file:///tmp/page.wiki").unwrap(); - let edit = ops::toc_edit(src, &ast, &uri, true, "Contents", 1, 0).expect("got an edit"); + let edit = ops::toc_edit(src, &ast, &uri, true, "Contents", 1, 0, 0).expect("got an edit"); let changes = edit.changes.expect("changes map"); let edits = &changes[&uri]; assert_eq!(edits.len(), 1); @@ -468,7 +497,7 @@ fn toc_edit_replaces_existing_toc() { let src = "= Contents =\n- [[#stale|Stale]]\n\n= Real =\n"; let ast = parse(src); let uri = Url::parse("file:///tmp/page.wiki").unwrap(); - let edit = ops::toc_edit(src, &ast, &uri, true, "Contents", 1, 0).expect("got an edit"); + let edit = ops::toc_edit(src, &ast, &uri, true, "Contents", 1, 0, 0).expect("got an edit"); let changes = edit.changes.expect("changes map"); let edits = &changes[&uri]; let te = &edits[0]; @@ -483,7 +512,7 @@ fn toc_edit_returns_none_for_empty_doc() { let src = ""; let ast = parse(src); let uri = Url::parse("file:///tmp/empty.wiki").unwrap(); - assert!(ops::toc_edit(src, &ast, &uri, true, "Contents", 1, 0).is_none()); + assert!(ops::toc_edit(src, &ast, &uri, true, "Contents", 1, 0, 0).is_none()); } #[test] @@ -494,7 +523,7 @@ fn toc_rebuild_edit_only_acts_when_toc_already_present() { let without = "= One =\n== Two ==\n"; let ast = parse(without); assert!( - ops::toc_rebuild_edit(without, &ast, &uri, true, "Contents", 1, 0).is_none(), + ops::toc_rebuild_edit(without, &ast, &uri, true, "Contents", 1, 0, 0).is_none(), "auto_toc should not insert a TOC where none existed" ); @@ -502,7 +531,7 @@ fn toc_rebuild_edit_only_acts_when_toc_already_present() { let with = "= Contents =\n- [[#stale|Stale]]\n\n= Real =\n"; let ast = parse(with); let edit = - ops::toc_rebuild_edit(with, &ast, &uri, true, "Contents", 1, 0).expect("rebuild edit"); + ops::toc_rebuild_edit(with, &ast, &uri, true, "Contents", 1, 0, 0).expect("rebuild edit"); let te = &edit.changes.unwrap()[&uri][0]; assert!(te.new_text.contains("[[#real|Real]]")); assert!(!te.new_text.contains("Stale")); @@ -526,6 +555,7 @@ fn links_edit_inserts_when_section_absent() { "Generated Links", 1, 0, + None, ) .expect("edit"); let te = &edit.changes.unwrap()[&uri][0]; @@ -550,6 +580,7 @@ fn links_edit_replaces_when_section_present() { "Generated Links", 1, 0, + None, ) .expect("edit"); let te = &edit.changes.unwrap()[&uri][0]; @@ -563,7 +594,19 @@ fn links_edit_returns_none_for_empty_page_list() { let src = "Hi\n"; let ast = parse(src); let uri = Url::parse("file:///tmp/page.wiki").unwrap(); - assert!(ops::links_edit(src, &ast, &uri, "Home", &[], true, "Generated Links", 1, 0).is_none()); + assert!(ops::links_edit( + src, + &ast, + &uri, + "Home", + &[], + true, + "Generated Links", + 1, + 0, + None + ) + .is_none()); } // ===== find_orphans ===== diff --git a/ftplugin/vimwiki.vim b/ftplugin/vimwiki.vim index 066d6a3..4b339b3 100644 --- a/ftplugin/vimwiki.vim +++ b/ftplugin/vimwiki.vim @@ -12,7 +12,8 @@ if exists('b:did_ftplugin') endif let b:did_ftplugin = 1 -setlocal commentstring=%%\ %s +" vimwiki's commentstring is `%%%s` (no space); match it exactly. +setlocal commentstring=%%%s setlocal comments=:%% setlocal formatoptions+=ron setlocal suffixesadd=.wiki @@ -62,6 +63,15 @@ if !has('nvim') augroup END let b:undo_ftplugin .= ' | execute "autocmd! NuwikiTableAutoFmt * "' endif + " vimwiki `auto_chdir` (default off): lcd into the owning wiki's root. + if get(g:, 'nuwiki_auto_chdir', 0) + augroup NuwikiAutoChdir + autocmd! * + autocmd BufEnter,BufWinEnter call nuwiki#commands#auto_chdir() + augroup END + call nuwiki#commands#auto_chdir() + let b:undo_ftplugin .= ' | execute "autocmd! NuwikiAutoChdir * "' + endif endif if !has('nvim') diff --git a/lua/nuwiki/config.lua b/lua/nuwiki/config.lua index c6035e6..9b61159 100644 --- a/lua/nuwiki/config.lua +++ b/lua/nuwiki/config.lua @@ -70,6 +70,10 @@ M.defaults = { -- cursor when leaving insert mode. table_auto_fmt = true, + -- vimwiki `auto_chdir` (default off): `:lcd` into the owning wiki's root + -- when a wiki buffer becomes current. + auto_chdir = false, + -- Broken-link diagnostic severity. Sent to the server, which downgrades -- or suppresses the diagnostic accordingly. -- 'off' | 'hint' | 'warn' | 'error' @@ -125,4 +129,17 @@ function M.wiki_list() return list end +-- Absolute root (with trailing slash) of the wiki that owns `path`, or the +-- first configured wiki as a fallback. Used by auto_chdir + search scoping. +function M.wiki_root_for(path) + for _, w in ipairs(M.wiki_list()) do + local wroot = vim.fn.fnamemodify(vim.fn.expand(w.root), ':p') + if path:sub(1, #wroot) == wroot then + return wroot + end + end + local first = M.wiki_list()[1] + return first and vim.fn.fnamemodify(vim.fn.expand(first.root), ':p') or nil +end + return M diff --git a/lua/nuwiki/ftplugin.lua b/lua/nuwiki/ftplugin.lua index 3970e01..b6dbfbd 100644 --- a/lua/nuwiki/ftplugin.lua +++ b/lua/nuwiki/ftplugin.lua @@ -94,6 +94,31 @@ local function setup_table_auto_fmt(bufnr, enabled) }) end +-- vimwiki `auto_chdir`: `:lcd` into the owning wiki's root while this buffer +-- is current. +local function setup_auto_chdir(bufnr, enabled) + if not enabled then + return + end + if bufnr == 0 then + bufnr = vim.api.nvim_get_current_buf() + end + local function chdir() + local file = vim.api.nvim_buf_get_name(bufnr) + local root = require('nuwiki.config').wiki_root_for(file) + if root then + vim.cmd('lcd ' .. vim.fn.fnameescape(root)) + end + end + local grp = vim.api.nvim_create_augroup('nuwiki_chdir_' .. bufnr, { clear = true }) + vim.api.nvim_create_autocmd({ 'BufEnter', 'BufWinEnter' }, { + buffer = bufnr, + group = grp, + callback = chdir, + }) + chdir() +end + function M.attach(bufnr) bufnr = bufnr or 0 local config = require('nuwiki.config') @@ -109,6 +134,7 @@ function M.attach(bufnr) setup_folding(bufnr, opts.folding or 'lsp') setup_autowriteall(bufnr, opts.autowriteall ~= false) setup_table_auto_fmt(bufnr, opts.table_auto_fmt ~= false) + setup_auto_chdir(bufnr, opts.auto_chdir == true) end return M