From 63e5b6d5144827ed3bba24d8cd582530a5d6e61c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriel=20Fr=C3=B3es=20Franco?= Date: Mon, 11 May 2026 21:57:05 +0000 Subject: [PATCH] phase 19: editor glue v2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server-side: - New `folding.rs` module with `folding_ranges(ast, total_lines)`. Emits one fold per top-level heading (start → line before the next same-or-higher-level heading, or EOF) plus one per top-level list block. Nested headings fold inside their parent thanks to the level-aware end-line computation; sublists fold implicitly via their parent item's source span. `FoldingRangeKind::Region` is set on every fold so collapsing UIs render them as section folds. - `Backend::folding_range` handler wires the module into `textDocument/foldingRange`; `ServerCapabilities.folding_range_provider` advertises it. - `folding::line_count` exposed for the handler and tests; treats a trailing newline as a virtual empty line (the line LSP positions use for EOF). Editor glue (Neovim primary, Vim minimal): - `lua/nuwiki/commands.lua` — async `workspace/executeCommand` wrappers for every server command shipped through Phase 18. The open-* family handles `{ uri }` responses by opening the file (with `tab` / `split` variants). `check_links` and `find_orphans` hand results to `setqflist` + `:copen` for quickfix-style review. §13.1-deferred commands (`list.changeSymbol`, table rewriters, `link.pasteWikilink/pasteUrl`, `colorize`) stub out with `vim.notify` so users get a clear "not yet implemented" signal instead of an LSP "unknown command" error. - `lua/nuwiki/keymaps.lua` — buffer-local default mappings. Subgroups (`list_editing`, `header_nav`, `diary`, `html_export`, `text_objects`) flip independently via the new `mappings.` config. Heading promote/demote uses `g=`/`g-` to avoid clobbering Vim's built-in `=`/`-` operators. - `lua/nuwiki/textobjects.lua` — `ah`/`ih` (around/inside heading) using a buffer scan for the heading-block boundary. The four remaining text objects from SPEC §12.10 wait until §13.1 lands the table/list rewriters they share infrastructure with. - `lua/nuwiki/folding.lua` — pure regex `foldexpr` + `foldtext` fallback for clients without `foldingRange`. Same heading-block model as the server. - `lua/nuwiki/ftplugin.lua` — single per-buffer attach entry point. `folding = 'lsp'` (default) uses Neovim 0.11+'s `vim.lsp.foldexpr`, falling back to the regex on older versions; `'expr'` forces the fallback; `'off'` skips folding setup. - `lua/nuwiki/config.lua` — extends defaults with `mappings = {...}` (P10 keymap layer) and `folding` (P14 resolved). - `ftplugin/nuwiki.vim` — declares every `:Vimwiki*` / `:Nuwiki*` command from SPEC §12.10 by inlining `lua require(...).fn()` bodies (no script-local function indirection so commands stay callable after re-source). Plain-Vim users get only the buffer options; they're expected to drive the LSP via vim-lsp / coc's built-in commands. Health check (§12.10 additions): - `:checkhealth nuwiki` now reports the count of `executeCommand` entries the server advertises, whether `foldingRange` capability was negotiated, and whether the configured HTML output directory exists + is writable. Default keymap subgroup status is also surfaced. Tests: 8 new in `phase19_folding.rs` covering empty docs, single heading → EOF, sibling headings each getting their own fold, nested heading bounded by parent, top-level list folds, single-line heading non-fold, fold-kind classification, and `line_count` semantics. Total 377 tests pass; fmt + clippy clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 2 +- SPEC.md | 27 +- crates/nuwiki-lsp/src/folding.rs | 88 ++++++ crates/nuwiki-lsp/src/lib.rs | 25 +- crates/nuwiki-lsp/tests/phase19_folding.rs | 111 +++++++ ftplugin/nuwiki.vim | 113 ++++++- lua/nuwiki/commands.lua | 348 +++++++++++++++++++++ lua/nuwiki/config.lua | 19 +- lua/nuwiki/folding.lua | 42 +++ lua/nuwiki/ftplugin.lua | 57 ++++ lua/nuwiki/health.lua | 91 +++++- lua/nuwiki/keymaps.lua | 73 +++++ lua/nuwiki/textobjects.lua | 64 ++++ 13 files changed, 1029 insertions(+), 31 deletions(-) create mode 100644 crates/nuwiki-lsp/src/folding.rs create mode 100644 crates/nuwiki-lsp/tests/phase19_folding.rs create mode 100644 lua/nuwiki/commands.lua create mode 100644 lua/nuwiki/folding.lua create mode 100644 lua/nuwiki/ftplugin.lua create mode 100644 lua/nuwiki/keymaps.lua create mode 100644 lua/nuwiki/textobjects.lua diff --git a/README.md b/README.md index 621011b..3ff07ea 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ See [`SPEC.md`](./SPEC.md) for the full project specification. | 16 | Diary | ✅ done | | 17 | HTML export commands | ✅ done | | 18 | Multi-wiki | ✅ done | -| 19 | Editor glue v2 (`:Vimwiki*` compat, keymaps, text objects, folding) | ⏳ | +| 19 | Editor glue v2 (`:Vimwiki*` compat, keymaps, text objects, folding) | ✅ done | ## Repository layout diff --git a/SPEC.md b/SPEC.md index f9a577b..9cd61ea 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1382,14 +1382,12 @@ Deferred because the value proposition over the existing `workspace/rename` flow is marginal and the editor-side support is inconsistent across clients. -### 13.3 v1.1 phase 19 +### 13.3 v1.1 status -Tracked in §10 with status `⏳`. Not "missing" from earlier phases — -it's future work in the v1.1 roadmap: - -- **Phase 19** — Editor glue v2: `:Vimwiki*` compat layer, default - keymaps, text objects, folding (P14 already resolved in favour of - LSP `foldingRange` + `foldexpr` fallback). +All numbered phases through 19 are shipped. The remaining work is +the §13.1 deferred Phase 14 command surface (table/list rewriters, +link helpers, colorize) and the small follow-ups noted under §13.2 / +§13.4. Earlier v1.1 phases now shipped: @@ -1409,6 +1407,21 @@ Earlier v1.1 phases now shipped: picker commands: `nuwiki.wiki.listAll`, `nuwiki.wiki.select`, `nuwiki.wiki.openIndex`, `nuwiki.wiki.tabOpenIndex`, plus `nuwiki.wiki.gotoPage` for the `:VimwikiGoto` compat surface. +- Phase 19 (editor glue v2) — LSP `textDocument/foldingRange` + provider (`folding::folding_ranges`) with heading-block + top-level + list semantics; advertised via `folding_range_provider`. A pure + Lua `foldexpr` fallback ships in `lua/nuwiki/folding.lua` for + clients without `foldingRange` support. `ftplugin/nuwiki.vim` + defines the full `:Vimwiki*` compat surface plus `:Nuwiki*` + aliases — every command that maps to a server-side `executeCommand` + is wired; §13.1-deferred commands stub out with a "not yet + implemented" notification so users get a clear signal. Default + buffer-local keymaps cover the working subset (checkbox toggle, + next task, heading promote/demote via `g=`/`g-`, diary navigation + via ``/``, HTML export under `wh*`). A + heading text object (`ah`/`ih`) ships now; `aH`/`iH`/`a\`/`i\`/ + `ac`/`ic`/`al`/`il` land once the §13.1 table/list rewriters + arrive. ### 13.4 §9 syntax checklist status diff --git a/crates/nuwiki-lsp/src/folding.rs b/crates/nuwiki-lsp/src/folding.rs new file mode 100644 index 0000000..49b1718 --- /dev/null +++ b/crates/nuwiki-lsp/src/folding.rs @@ -0,0 +1,88 @@ +//! `textDocument/foldingRange` provider — SPEC §12.10 / P14. +//! +//! Strategy: one fold per heading (line → line before the next heading +//! of same-or-higher level) plus one fold per top-level list block. +//! Folding is deliberately conservative — we never produce nested folds +//! beyond what the AST already groups, since vimwiki users mostly want +//! "collapse this section" behaviour rather than fine-grained code-fold +//! semantics. + +use nuwiki_core::ast::{BlockNode, DocumentNode}; +use tower_lsp::lsp_types::{FoldingRange, FoldingRangeKind}; + +/// Compute folding ranges for the whole document. `total_lines` is the +/// number of lines in the source (`text.lines().count() as u32`) — used +/// to extend the last heading's fold to end-of-document. +pub fn folding_ranges(ast: &DocumentNode, total_lines: u32) -> Vec { + let mut out = Vec::new(); + let last_line = total_lines.saturating_sub(1); + + // Collect heading spans + levels in source order. We walk the + // top-level children only — nested headings inside blockquotes are + // rare and folding them adds complexity for little gain. + let mut headings: Vec<(u32, u8)> = Vec::new(); + for block in &ast.children { + if let BlockNode::Heading(h) = block { + headings.push((h.span.start.line, h.level)); + } + } + // For each heading, end-line is one before the next heading whose + // level is <= current level (or last_line otherwise). + for (i, &(line, level)) in headings.iter().enumerate() { + let end = headings[i + 1..] + .iter() + .find(|(_, l)| *l <= level) + .map(|(next_line, _)| next_line.saturating_sub(1)) + .unwrap_or(last_line); + if end > line { + out.push(FoldingRange { + start_line: line, + end_line: end, + start_character: None, + end_character: None, + kind: Some(FoldingRangeKind::Region), + collapsed_text: None, + }); + } + } + + // Fold each top-level list. Sublists fold implicitly because their + // parent item's source span already covers them. + for block in &ast.children { + if let BlockNode::List(l) = block { + let start = l.span.start.line; + let end = l.span.end.line; + if end > start { + out.push(FoldingRange { + start_line: start, + end_line: end, + start_character: None, + end_character: None, + kind: Some(FoldingRangeKind::Region), + collapsed_text: None, + }); + } + } + } + + out +} + +/// Count lines in `text` (always ≥ 1 for non-empty input). +pub fn line_count(text: &str) -> u32 { + if text.is_empty() { + 0 + } else { + let mut n = text.bytes().filter(|b| *b == b'\n').count() as u32; + if !text.ends_with('\n') { + n += 1; + } else { + // trailing newline still bounds a virtual line; LSP positions + // index 0-based, so the file ends at line `n` (the empty one + // after the final newline). We treat "lines" as the count + // *including* that trailing empty line for fold-end clamping. + n += 1; + } + n + } +} diff --git a/crates/nuwiki-lsp/src/lib.rs b/crates/nuwiki-lsp/src/lib.rs index ce1f782..d297cdc 100644 --- a/crates/nuwiki-lsp/src/lib.rs +++ b/crates/nuwiki-lsp/src/lib.rs @@ -27,6 +27,7 @@ pub mod diagnostics; pub mod diary; pub mod edits; pub mod export; +pub mod folding; pub mod index; pub mod nav; pub mod rename; @@ -47,11 +48,12 @@ use tower_lsp::lsp_types::{ DidChangeTextDocumentParams, DidCloseTextDocumentParams, DidOpenTextDocumentParams, DidSaveTextDocumentParams, DocumentSymbol, DocumentSymbolParams, DocumentSymbolResponse, ExecuteCommandOptions, ExecuteCommandParams, FileOperationFilter, FileOperationPattern, - FileOperationPatternKind, FileOperationRegistrationOptions, GotoDefinitionParams, - GotoDefinitionResponse, Hover, HoverContents, HoverParams, HoverProviderCapability, - InitializeParams, InitializeResult, InitializedParams, Location, MarkupContent, MarkupKind, - MessageType, OneOf, PositionEncodingKind, ProgressParams, ProgressParamsValue, ProgressToken, - Range, ReferenceParams, RenameFilesParams, RenameParams, SaveOptions, SemanticTokens, + FileOperationPatternKind, FileOperationRegistrationOptions, FoldingRange, FoldingRangeParams, + FoldingRangeProviderCapability, GotoDefinitionParams, GotoDefinitionResponse, Hover, + HoverContents, HoverParams, HoverProviderCapability, InitializeParams, InitializeResult, + InitializedParams, Location, MarkupContent, MarkupKind, MessageType, OneOf, + PositionEncodingKind, ProgressParams, ProgressParamsValue, ProgressToken, Range, + ReferenceParams, RenameFilesParams, RenameParams, SaveOptions, SemanticTokens, SemanticTokensFullOptions, SemanticTokensOptions, SemanticTokensParams, SemanticTokensRangeParams, SemanticTokensRangeResult, SemanticTokensResult, SemanticTokensServerCapabilities, ServerCapabilities, ServerInfo, @@ -408,6 +410,7 @@ impl LanguageServer for Backend { workspace_folders: None, file_operations: Some(wiki_file_operations()), }), + folding_range_provider: Some(FoldingRangeProviderCapability::Simple(true)), ..ServerCapabilities::default() }, server_info: Some(ServerInfo { @@ -839,6 +842,18 @@ impl LanguageServer for Backend { } } + async fn folding_range( + &self, + params: FoldingRangeParams, + ) -> LspResult>> { + let uri = ¶ms.text_document.uri; + let Some(doc) = self.documents.get(uri) else { + return Ok(None); + }; + let total = folding::line_count(&doc.text); + Ok(Some(folding::folding_ranges(&doc.ast, total))) + } + async fn symbol( &self, params: WorkspaceSymbolParams, diff --git a/crates/nuwiki-lsp/tests/phase19_folding.rs b/crates/nuwiki-lsp/tests/phase19_folding.rs new file mode 100644 index 0000000..e6a587e --- /dev/null +++ b/crates/nuwiki-lsp/tests/phase19_folding.rs @@ -0,0 +1,111 @@ +//! Phase 19: LSP folding-range provider. +//! +//! Drives `folding::folding_ranges` directly. The handler wiring is +//! shallow — it just calls into this function — so we verify the +//! shapes here and trust the integration via the LSP test harness in +//! `lsp_helpers.rs`. + +use nuwiki_core::syntax::vimwiki::VimwikiSyntax; +use nuwiki_core::syntax::SyntaxPlugin; +use nuwiki_lsp::folding; + +fn parse(src: &str) -> nuwiki_core::ast::DocumentNode { + VimwikiSyntax::new().parse(src) +} + +#[test] +fn no_headings_no_lists_emits_no_folds() { + let src = "just text\non two lines\n"; + let ast = parse(src); + let folds = folding::folding_ranges(&ast, folding::line_count(src)); + assert!(folds.is_empty()); +} + +#[test] +fn single_heading_folds_to_end_of_document() { + let src = "= Title =\nbody line 1\nbody line 2\n"; + let ast = parse(src); + let folds = folding::folding_ranges(&ast, folding::line_count(src)); + assert_eq!(folds.len(), 1); + assert_eq!(folds[0].start_line, 0); + // body line 2 is line 2; trailing newline pushes line_count to 4 so + // last_line == 3 (the empty line after the final newline). + assert!(folds[0].end_line >= 2); +} + +#[test] +fn sibling_headings_each_get_their_own_fold() { + let src = "= One =\nbody\n= Two =\nbody2\n= Three =\n"; + let ast = parse(src); + let folds = folding::folding_ranges(&ast, folding::line_count(src)); + let heading_folds: Vec<_> = folds + .iter() + .filter(|f| f.start_line == 0 || f.start_line == 2 || f.start_line == 4) + .collect(); + assert_eq!(heading_folds.len(), 3, "got: {folds:?}"); + // One closes before Two + assert_eq!(heading_folds[0].end_line, 1); + // Two closes before Three + assert_eq!(heading_folds[1].end_line, 3); +} + +#[test] +fn nested_heading_folds_inside_parent() { + let src = "= Outer =\nintro\n== Inner ==\nbody\n= Other =\n"; + let ast = parse(src); + let folds = folding::folding_ranges(&ast, folding::line_count(src)); + // Outer fold spans up to the line before `= Other =` (line 4). + let outer = folds + .iter() + .find(|f| f.start_line == 0) + .expect("outer fold"); + assert_eq!(outer.end_line, 3); + // Inner fold is bounded by the next h1 too (since the only following + // heading is h1, which has level <= 2). + let inner = folds + .iter() + .find(|f| f.start_line == 2) + .expect("inner fold"); + assert_eq!(inner.end_line, 3); +} + +#[test] +fn top_level_list_gets_its_own_fold() { + let src = "before\n- item one\n- item two\n- item three\nafter\n"; + let ast = parse(src); + let folds = folding::folding_ranges(&ast, folding::line_count(src)); + let list_fold = folds.iter().find(|f| f.start_line == 1); + assert!(list_fold.is_some(), "expected a list fold: {folds:?}"); +} + +#[test] +fn single_line_blocks_are_not_folded() { + let src = "= Solo =\n"; + let ast = parse(src); + let folds = folding::folding_ranges(&ast, folding::line_count(src)); + // A heading on the only line of the document covers nothing + // foldable, so it's omitted. + let none_for_solo = folds + .iter() + .all(|f| !(f.start_line == 0 && f.end_line == 0)); + assert!(none_for_solo, "got: {folds:?}"); +} + +#[test] +fn fold_kind_is_region() { + use tower_lsp::lsp_types::FoldingRangeKind; + let src = "= H =\nbody\n"; + let ast = parse(src); + let folds = folding::folding_ranges(&ast, folding::line_count(src)); + assert!(!folds.is_empty()); + assert_eq!(folds[0].kind, Some(FoldingRangeKind::Region)); +} + +#[test] +fn line_count_matches_intuitive_definition() { + assert_eq!(folding::line_count(""), 0); + assert_eq!(folding::line_count("one"), 1); + assert_eq!(folding::line_count("one\n"), 2); // trailing newline → virtual empty line + assert_eq!(folding::line_count("one\ntwo"), 2); + assert_eq!(folding::line_count("one\ntwo\n"), 3); +} diff --git a/ftplugin/nuwiki.vim b/ftplugin/nuwiki.vim index 748e302..d8d514d 100644 --- a/ftplugin/nuwiki.vim +++ b/ftplugin/nuwiki.vim @@ -1,8 +1,12 @@ -" ftplugin/nuwiki.vim — per-buffer settings for `.wiki` buffers. +" ftplugin/nuwiki.vim — per-buffer settings + Phase 19 command/keymap glue. " -" Keeps this minimal: only the options that affect editing ergonomics for -" markup files. Anything richer (folding, navigation keymaps, …) ships -" downstream once users opt in. +" Wires up: +" - basic edit-time options (commentstring, suffixesadd, …) +" - every `:Vimwiki*` / `:Nuwiki*` command from SPEC §12.10 that has a +" server-side counterpart (deferred §13.1 commands stub out with a +" "not yet implemented" notification) +" - on Neovim: keymaps + text objects + folding via +" `nuwiki.ftplugin.attach()` if exists('b:did_ftplugin') finish @@ -16,3 +20,104 @@ setlocal suffixesadd=.wiki setlocal iskeyword+=- let b:undo_ftplugin = 'setlocal commentstring< comments< formatoptions< suffixesadd< iskeyword<' + +if !has('nvim') + " Plain Vim — the buffer-local options above are all we ship. Vim users + " interact via the LSP client (vim-lsp / coc) which already exposes + " `:LspDefinition`, `:LspReferences`, `:LspRename`, etc. + finish +endif + +" ===== Neovim path ===== + +" :Vimwiki* compat — every command dispatches to a Lua wrapper in +" `nuwiki.commands` that either issues a `workspace/executeCommand` +" or, for client-side operations, calls `vim.lsp.buf.*` directly. + +command! -buffer -count VimwikiIndex lua require('nuwiki.commands').wiki_index(vim.v.count) +command! -buffer -count VimwikiTabIndex lua require('nuwiki.commands').wiki_tab_index(vim.v.count) +command! -buffer VimwikiUISelect lua require('nuwiki.commands').wiki_ui_select() +command! -buffer VimwikiDiaryIndex lua require('nuwiki.commands').diary_index() +command! -buffer VimwikiMakeDiaryNote lua require('nuwiki.commands').diary_today() +command! -buffer VimwikiMakeYesterdayDiaryNote lua require('nuwiki.commands').diary_yesterday() +command! -buffer VimwikiMakeTomorrowDiaryNote lua require('nuwiki.commands').diary_tomorrow() +command! -buffer VimwikiDiaryNextDay lua require('nuwiki.commands').diary_next() +command! -buffer VimwikiDiaryPrevDay lua require('nuwiki.commands').diary_prev() +command! -buffer VimwikiDiaryGenerateLinks lua require('nuwiki.commands').diary_generate_index() + +command! -buffer VimwikiFollowLink lua vim.lsp.buf.definition() +command! -buffer VimwikiGoBackLink normal! +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 -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() +command! -buffer -nargs=1 VimwikiSearch lvimgrep // ** +command! -buffer -nargs=1 VWS lvimgrep // ** + +command! -buffer VimwikiDeleteFile lua require('nuwiki.commands').delete_file() +command! -buffer VimwikiRenameFile lua require('nuwiki.commands').rename_file() + +command! -buffer VimwikiNextTask lua require('nuwiki.commands').next_task() +command! -buffer VimwikiToggleListItem lua require('nuwiki.commands').toggle_list_item() +command! -buffer VimwikiToggleRejectedListItem lua require('nuwiki.commands').reject_list_item() +command! -buffer VimwikiRemoveDone lua require('nuwiki.commands').list_remove_done() +command! -buffer -nargs=? VimwikiListChangeLvl lua require('nuwiki.commands').list_change_lvl() + +command! -buffer Vimwiki2HTML lua require('nuwiki.commands').export_current() +command! -buffer Vimwiki2HTMLBrowse lua require('nuwiki.commands').export_browse() +command! -buffer -bang VimwikiAll2HTML execute (0 ? "lua require('nuwiki.commands').export_all_force()" : "lua require('nuwiki.commands').export_all()") +command! -buffer VimwikiRss lua require('nuwiki.commands').export_rss() + +command! -buffer VimwikiTOC lua require('nuwiki.commands').toc_generate() +command! -buffer VimwikiGenerateLinks lua require('nuwiki.commands').links_generate() +command! -buffer VimwikiCheckLinks lua require('nuwiki.commands').check_links() +command! -buffer VimwikiFindOrphans lua require('nuwiki.commands').find_orphans() + +command! -buffer VimwikiRebuildTags lua require('nuwiki.commands').tags_rebuild() +command! -buffer -nargs=? VimwikiSearchTags lua require('nuwiki.commands').tags_search() +command! -buffer -nargs=? VimwikiGenerateTagLinks lua require('nuwiki.commands').tags_generate_links() + +" §13.1 deferred — stubs notify the user instead of erroring. +command! -buffer -nargs=1 VimwikiTable lua require('nuwiki.commands').table_insert() +command! -buffer VimwikiTableMoveColumnLeft lua require('nuwiki.commands').table_move_column_left() +command! -buffer VimwikiTableMoveColumnRight lua require('nuwiki.commands').table_move_column_right() +command! -buffer -nargs=1 VimwikiColorize lua require('nuwiki.commands').colorize() +command! -buffer VimwikiPasteLink lua require('nuwiki.commands').paste_link() +command! -buffer VimwikiPasteUrl lua require('nuwiki.commands').paste_url() + +" Canonical :Nuwiki* aliases — P10. +command! -buffer -count NuwikiIndex lua require('nuwiki.commands').wiki_index(vim.v.count) +command! -buffer NuwikiTabIndex lua require('nuwiki.commands').wiki_tab_index(vim.v.count) +command! -buffer NuwikiUISelect lua require('nuwiki.commands').wiki_ui_select() +command! -buffer NuwikiDiaryIndex lua require('nuwiki.commands').diary_index() +command! -buffer NuwikiDiaryToday lua require('nuwiki.commands').diary_today() +command! -buffer NuwikiDiaryYesterday lua require('nuwiki.commands').diary_yesterday() +command! -buffer NuwikiDiaryTomorrow lua require('nuwiki.commands').diary_tomorrow() +command! -buffer NuwikiDiaryNext lua require('nuwiki.commands').diary_next() +command! -buffer NuwikiDiaryPrev lua require('nuwiki.commands').diary_prev() +command! -buffer NuwikiDiaryGenerateLinks lua require('nuwiki.commands').diary_generate_index() +command! -buffer NuwikiFollowLink lua vim.lsp.buf.definition() +command! -buffer NuwikiBacklinks lua vim.lsp.buf.references() +command! -buffer NuwikiDeleteFile lua require('nuwiki.commands').delete_file() +command! -buffer NuwikiRenameFile lua require('nuwiki.commands').rename_file() +command! -buffer NuwikiNextTask lua require('nuwiki.commands').next_task() +command! -buffer NuwikiToggleListItem lua require('nuwiki.commands').toggle_list_item() +command! -buffer NuwikiToggleRejected lua require('nuwiki.commands').reject_list_item() +command! -buffer NuwikiExport lua require('nuwiki.commands').export_current() +command! -buffer NuwikiExportBrowse lua require('nuwiki.commands').export_browse() +command! -buffer -bang NuwikiExportAll execute (0 ? "lua require('nuwiki.commands').export_all_force()" : "lua require('nuwiki.commands').export_all()") +command! -buffer NuwikiRss lua require('nuwiki.commands').export_rss() +command! -buffer NuwikiTOC lua require('nuwiki.commands').toc_generate() +command! -buffer NuwikiGenerateLinks lua require('nuwiki.commands').links_generate() +command! -buffer NuwikiCheckLinks lua require('nuwiki.commands').check_links() +command! -buffer NuwikiFindOrphans lua require('nuwiki.commands').find_orphans() +command! -buffer NuwikiRebuildTags lua require('nuwiki.commands').tags_rebuild() +command! -buffer -nargs=? NuwikiSearchTags lua require('nuwiki.commands').tags_search() +command! -buffer -nargs=? NuwikiGenerateTagLinks lua require('nuwiki.commands').tags_generate_links() +command! -buffer -nargs=1 NuwikiGoto lua require('nuwiki.commands').wiki_goto_page() + +" Phase 19 buffer attach: keymaps + text objects + folding. +lua require('nuwiki.ftplugin').attach(0) diff --git a/lua/nuwiki/commands.lua b/lua/nuwiki/commands.lua new file mode 100644 index 0000000..ec87423 --- /dev/null +++ b/lua/nuwiki/commands.lua @@ -0,0 +1,348 @@ +-- lua/nuwiki/commands.lua — Lua wrappers around every `nuwiki.*` +-- `workspace/executeCommand` the server advertises. Phase 19 plumbing. +-- +-- Each public function is named after its `:Vimwiki*` / `:Nuwiki*` +-- counterpart so `ftplugin/nuwiki.vim` can wire them up declaratively. +-- All requests run asynchronously; commands that return a `{ uri }` +-- payload open the result in the current window (or a new tab, when +-- the variant is `*_tab`). + +local M = {} + +local function buf_uri(bufnr) + return vim.uri_from_bufnr(bufnr or 0) +end + +local function position_params() + return vim.lsp.util.make_position_params() +end + +local function find_client() + local fn = vim.lsp.get_clients or vim.lsp.get_active_clients + local clients = fn({ name = 'nuwiki', bufnr = 0 }) + if #clients == 0 then + -- Try without bufnr filter for workspace-level commands. + clients = fn({ name = 'nuwiki' }) + end + return clients[1] +end + +local function exec(command, arguments, on_result) + local client = find_client() + if not client then + vim.notify('nuwiki: language server not attached', vim.log.levels.ERROR) + return + end + client.request( + 'workspace/executeCommand', + { command = command, arguments = arguments or {} }, + function(err, result) + if err then + vim.notify( + 'nuwiki: ' .. command .. ' — ' .. (err.message or vim.inspect(err)), + vim.log.levels.ERROR + ) + return + end + if on_result then + on_result(result) + end + end, + vim.api.nvim_get_current_buf() + ) +end + +local function open_uri(uri, tab) + if not uri then + return + end + local path = vim.uri_to_fname(uri) + local cmd + if tab == 'split' then + cmd = 'split' + elseif tab == 'vsplit' then + cmd = 'vsplit' + elseif tab == true then + cmd = 'tabedit' + else + cmd = 'edit' + end + vim.cmd(cmd .. ' ' .. vim.fn.fnameescape(path)) +end + +local function pos_args() + return { vim.tbl_extend('force', { uri = buf_uri() }, position_params()) } +end + +local function uri_args() + return { { uri = buf_uri() } } +end + +-- ===== Wiki picker ===== + +function M.wiki_index(count) + local args = { {} } + if count and count > 0 then + args = { { wiki = count - 1 } } -- 1-indexed at the user level + end + exec('nuwiki.wiki.openIndex', args, function(r) + if r and r.uri then + open_uri(r.uri, r.tab) + end + end) +end + +function M.wiki_tab_index(count) + local args = { {} } + if count and count > 0 then + args = { { wiki = count - 1 } } + end + exec('nuwiki.wiki.tabOpenIndex', args, function(r) + if r and r.uri then + open_uri(r.uri, true) + end + end) +end + +function M.wiki_ui_select() + exec('nuwiki.wiki.select', { {} }, function(r) + if type(r) ~= 'table' or #r == 0 then + vim.notify('nuwiki: no wikis configured') + return + end + vim.ui.select( + r, + { + prompt = 'Pick a wiki', + format_item = function(w) + return string.format('%d. %s (%s)', (w.id or 0) + 1, w.name or '?', tostring(w.root)) + end, + }, + function(choice) + if not choice then return end + exec( + 'nuwiki.wiki.openIndex', + { { wiki = choice.id } }, + function(rr) if rr and rr.uri then open_uri(rr.uri) end end + ) + end + ) + end) +end + +function M.wiki_goto_page(name) + if not name or name == '' then + name = vim.fn.input('Goto page: ') + if name == '' then return end + end + exec('nuwiki.wiki.gotoPage', { { page = name } }, function(r) + if r and r.uri then open_uri(r.uri) end + end) +end + +-- ===== Diary ===== + +local function _diary_open(cmd_name) + return function() + exec(cmd_name, uri_args(), function(r) + if r and r.uri then open_uri(r.uri) end + end) + end +end + +M.diary_today = _diary_open('nuwiki.diary.openToday') +M.diary_yesterday = _diary_open('nuwiki.diary.openYesterday') +M.diary_tomorrow = _diary_open('nuwiki.diary.openTomorrow') +M.diary_index = _diary_open('nuwiki.diary.openIndex') + +function M.diary_next() + exec('nuwiki.diary.next', uri_args(), function(r) + if r and r.uri then open_uri(r.uri) end + end) +end + +function M.diary_prev() + exec('nuwiki.diary.prev', uri_args(), function(r) + if r and r.uri then open_uri(r.uri) end + end) +end + +function M.diary_generate_index() + -- Returns a WorkspaceEdit; the server's executeCommand handler will + -- have already applied it via applyEdit, so there's nothing to do here. + exec('nuwiki.diary.generateIndex', uri_args()) +end + +-- ===== List + heading editing ===== + +local function _exec_pos(cmd_name) + return function() exec(cmd_name, pos_args()) end +end + +M.toggle_list_item = _exec_pos('nuwiki.list.toggleCheckbox') +M.cycle_list_item = _exec_pos('nuwiki.list.cycleCheckbox') +M.reject_list_item = _exec_pos('nuwiki.list.rejectCheckbox') +M.heading_add_level = _exec_pos('nuwiki.heading.addLevel') +M.heading_remove_level = _exec_pos('nuwiki.heading.removeLevel') + +function M.next_task() + exec('nuwiki.list.nextTask', pos_args(), function(loc) + if loc and loc.range and loc.range.start then + vim.api.nvim_win_set_cursor(0, { + loc.range.start.line + 1, + loc.range.start.character, + }) + end + end) +end + +-- ===== Generation + workspace ===== + +M.toc_generate = function() exec('nuwiki.toc.generate', uri_args()) end +M.links_generate = function() exec('nuwiki.links.generate', uri_args()) end + +function M.check_links() + exec('nuwiki.workspace.checkLinks', uri_args(), function(r) + if type(r) ~= 'table' or #r == 0 then + vim.notify('nuwiki: no broken links') + return + end + local items = {} + for _, b in ipairs(r) do + table.insert(items, { + filename = vim.uri_to_fname(b.uri), + lnum = (b.range and b.range.start and b.range.start.line or 0) + 1, + col = (b.range and b.range.start and b.range.start.character or 0) + 1, + text = (b.kind or '?') .. ': ' .. (b.message or ''), + }) + end + vim.fn.setqflist({}, ' ', { title = 'nuwiki broken links', items = items }) + vim.cmd('copen') + end) +end + +function M.find_orphans() + exec('nuwiki.workspace.findOrphans', uri_args(), function(r) + if type(r) ~= 'table' or #r == 0 then + vim.notify('nuwiki: no orphan pages') + return + end + local items = {} + for _, o in ipairs(r) do + table.insert(items, { + filename = vim.uri_to_fname(o.uri), + text = o.name or '?', + }) + end + vim.fn.setqflist({}, ' ', { title = 'nuwiki orphans', items = items }) + vim.cmd('copen') + end) +end + +-- ===== Tags ===== + +function M.tags_search(query) + if not query or query == '' then + query = vim.fn.input('Search tags: ') + end + exec('nuwiki.tags.search', { { uri = buf_uri(), query = query } }, function(r) + if type(r) ~= 'table' or #r == 0 then + vim.notify('nuwiki: no tag matches') + return + end + local items = {} + for _, h in ipairs(r) do + table.insert(items, { + filename = vim.uri_to_fname(h.uri), + lnum = (h.range and h.range.start and h.range.start.line or 0) + 1, + col = (h.range and h.range.start and h.range.start.character or 0) + 1, + text = ':' .. h.name .. ': in ' .. (h.page or '?'), + }) + end + vim.fn.setqflist({}, ' ', { title = 'nuwiki tag search', items = items }) + vim.cmd('copen') + end) +end + +function M.tags_generate_links(tag) + local args = { uri = buf_uri() } + if tag and tag ~= '' then + args.tag = tag + end + exec('nuwiki.tags.generateLinks', { args }) +end + +function M.tags_rebuild() + exec('nuwiki.tags.rebuild', uri_args(), function(r) + if r and r.pages then + vim.notify(string.format('nuwiki: re-indexed %d page(s)', r.pages)) + end + end) +end + +-- ===== Export ===== + +M.export_current = function() exec('nuwiki.export.currentToHtml', uri_args()) end +M.export_all = function() exec('nuwiki.export.allToHtml', uri_args()) end +M.export_all_force = function() exec('nuwiki.export.allToHtmlForce', uri_args()) end +M.export_rss = function() exec('nuwiki.export.rss', uri_args()) end + +function M.export_browse() + exec('nuwiki.export.browse', uri_args(), function(r) + local url = r and r.browse + if not url then return end + -- Best-effort browser open. Falls back to a notification. + local opener + if vim.fn.has('mac') == 1 then + opener = 'open' + elseif vim.fn.has('unix') == 1 then + opener = 'xdg-open' + elseif vim.fn.has('win32') == 1 then + opener = 'start' + end + if opener then + vim.fn.jobstart({ opener, url }, { detach = true }) + else + vim.notify('nuwiki: exported → ' .. url) + end + end) +end + +-- ===== File ops ===== + +function M.delete_file() + local ans = vim.fn.input('Delete current page? [y/N] ') + if ans:lower() ~= 'y' then return end + exec('nuwiki.file.delete', { { uri = buf_uri() } }) +end + +function M.rename_file() + vim.lsp.buf.rename() +end + +-- ===== Deferred placeholders (§13.1) ===== +-- +-- These commands aren't implemented on the server yet. Stub them so the +-- `:Vimwiki*` compat surface exists and users get a clear message +-- instead of "unknown command". + +local function _not_yet(name) + return function() + vim.notify( + 'nuwiki: ' .. name .. ' is not yet implemented — see SPEC §13.1', + vim.log.levels.WARN + ) + end +end + +M.list_change_lvl = _not_yet(':VimwikiListChangeLvl') +M.list_renumber = _not_yet(':VimwikiRenumber') +M.list_remove_done = _not_yet(':VimwikiRemoveDone') +M.table_insert = _not_yet(':VimwikiTable') +M.table_move_column_left = _not_yet(':VimwikiTableMoveColumnLeft') +M.table_move_column_right = _not_yet(':VimwikiTableMoveColumnRight') +M.colorize = _not_yet(':VimwikiColorize') +M.paste_link = _not_yet(':VimwikiPasteLink') +M.paste_url = _not_yet(':VimwikiPasteUrl') + +return M diff --git a/lua/nuwiki/config.lua b/lua/nuwiki/config.lua index dfa6752..adc36d9 100644 --- a/lua/nuwiki/config.lua +++ b/lua/nuwiki/config.lua @@ -1,6 +1,7 @@ -- lua/nuwiki/config.lua — defaults and user-config merge. -- --- Schema mirrors SPEC §7.5. Keep this aligned with `doc/nuwiki.txt`. +-- Schema mirrors SPEC §7.5 + §12.11. Keep this aligned with +-- `doc/nuwiki.txt`. local M = {} @@ -9,6 +10,22 @@ M.defaults = { file_extension = '.wiki', syntax = 'vimwiki', log_level = 'warn', + + -- Phase 19: per-buffer glue. Each subgroup can be flipped off + -- independently to suppress the corresponding keymaps. + mappings = { + enabled = true, + list_editing = true, + header_nav = true, + diary = true, + html_export = true, + text_objects = true, + }, + + -- Phase 19: folding. `'lsp'` uses the server's `foldingRange` + -- provider (Neovim 0.11+ wires it automatically). `'expr'` falls + -- back to a regex-based `foldexpr`. `'off'` skips folding setup. + folding = 'lsp', } M.options = vim.deepcopy(M.defaults) diff --git a/lua/nuwiki/folding.lua b/lua/nuwiki/folding.lua new file mode 100644 index 0000000..8ef49a9 --- /dev/null +++ b/lua/nuwiki/folding.lua @@ -0,0 +1,42 @@ +-- lua/nuwiki/folding.lua — `foldexpr` fallback when the LSP isn't +-- attached. Same heading-block model as the server-side `foldingRange` +-- provider in `crates/nuwiki-lsp/src/folding.rs`, but driven from a +-- pure regex over `getline()` so it works without a running server. +-- +-- Activated by `setlocal foldmethod=expr foldexpr=v:lua.require'nuwiki.folding'.expr()` +-- in `ftplugin/nuwiki.vim` when `folding` is enabled in the user config. + +local M = {} + +--- Heading level from one line of source. Returns 0 for non-headings. +local function heading_level(line) + local lead = line:match('^%s*(=+)%s') + if not lead then return 0 end + local lvl = #lead + if lvl > 6 then return 0 end + -- Require a matching trailing run of `=`s. + local trail = line:match('%s(=+)%s*$') + if not trail or #trail ~= lvl then return 0 end + return lvl +end + +--- `foldexpr` body. Returns a fold-marker string per `:help fold-expr`. +function M.expr() + local lnum = vim.v.lnum + local line = vim.fn.getline(lnum) + local lvl = heading_level(line) + if lvl > 0 then + return '>' .. lvl + end + return '=' +end + +--- Build a heading-only `foldtext` showing the heading title trimmed. +function M.foldtext() + local fs = vim.v.foldstart + local line = vim.fn.getline(fs):gsub('=', ''):gsub('^%s+', ''):gsub('%s+$', '') + local count = vim.v.foldend - vim.v.foldstart + 1 + return string.format('▸ %s … (%d lines)', line, count) +end + +return M diff --git a/lua/nuwiki/ftplugin.lua b/lua/nuwiki/ftplugin.lua new file mode 100644 index 0000000..1b1ab9c --- /dev/null +++ b/lua/nuwiki/ftplugin.lua @@ -0,0 +1,57 @@ +-- lua/nuwiki/ftplugin.lua — per-buffer hookup invoked by +-- `ftplugin/nuwiki.vim`. Centralises the Lua side of Phase 19 so the +-- VimL ftplugin stays a one-liner regardless of which subgroups are +-- enabled. + +local M = {} + +local function setup_folding(bufnr, folding_mode) + if folding_mode == 'off' then + return + end + if folding_mode == 'expr' then + vim.api.nvim_set_option_value('foldmethod', 'expr', { buf = bufnr }) + vim.api.nvim_set_option_value( + 'foldexpr', + 'v:lua.require("nuwiki.folding").expr()', + { buf = bufnr } + ) + vim.api.nvim_set_option_value( + 'foldtext', + 'v:lua.require("nuwiki.folding").foldtext()', + { buf = bufnr } + ) + return + end + -- folding_mode == 'lsp' (default): rely on the server's + -- `foldingRange` capability. Neovim 0.11+ enables it automatically + -- via `vim.lsp.foldexpr`. Older versions get the regex fallback. + if vim.fn.has('nvim-0.11') == 1 and vim.lsp.foldexpr then + vim.api.nvim_set_option_value('foldmethod', 'expr', { buf = bufnr }) + vim.api.nvim_set_option_value( + 'foldexpr', + 'v:lua.vim.lsp.foldexpr()', + { buf = bufnr } + ) + else + -- Pre-0.11 — degrade to the regex fallback so users still get folds. + setup_folding(bufnr, 'expr') + end +end + +function M.attach(bufnr) + bufnr = bufnr or 0 + local config = require('nuwiki.config') + local opts = config.options or config.defaults + + if opts.mappings and opts.mappings.enabled ~= false then + require('nuwiki.keymaps').attach(bufnr, opts.mappings) + if opts.mappings.text_objects ~= false then + require('nuwiki.textobjects').attach(bufnr) + end + end + + setup_folding(bufnr, opts.folding or 'lsp') +end + +return M diff --git a/lua/nuwiki/health.lua b/lua/nuwiki/health.lua index 00a0814..524e04b 100644 --- a/lua/nuwiki/health.lua +++ b/lua/nuwiki/health.lua @@ -1,20 +1,16 @@ -- lua/nuwiki/health.lua — `:checkhealth nuwiki` target. -- --- Checks per SPEC §7.6: binary exists, is executable, LSP client running, --- filetype detection works for `.wiki`. +-- v1.0 checks per SPEC §7.6 (binary, version, filetype, LSP attach). +-- v1.1 §12.10 additions: registered LSP commands, indexed wikis, HTML +-- output path writability, default keymaps installed. local M = {} -function M.check() +local function bin_section() local install = require('nuwiki.install') - local config = require('nuwiki.config') - - vim.health.start('nuwiki') - local bin = install.expected_path() if install.is_installed() then vim.health.ok('binary present at ' .. bin) - local out = vim.fn.system({ bin, '--version' }) if vim.v.shell_error == 0 then vim.health.ok('binary --version: ' .. (out:gsub('\n', ' ')):gsub('^%s+', '')) @@ -30,8 +26,10 @@ function M.check() { 'Run :lua require("nuwiki").install() to install' } ) end +end - local root = vim.fn.expand(config.options.wiki_root or '') +local function wiki_root_section(opts) + local root = vim.fn.expand(opts.wiki_root or '') if root ~= '' then if vim.fn.isdirectory(root) == 1 then vim.health.ok('wiki_root exists: ' .. root) @@ -39,21 +37,88 @@ function M.check() vim.health.warn('wiki_root does not exist: ' .. root) end end +end +local function filetype_section() if vim.fn.exists('#filetypedetect#BufRead#*.wiki') ~= 0 or vim.filetype.match({ filename = 'test.wiki' }) == 'vimwiki' then vim.health.ok('filetype detection for *.wiki is wired up') else vim.health.warn('filetype detection for *.wiki not active') end +end - local clients = vim.lsp.get_clients and vim.lsp.get_clients({ name = 'nuwiki' }) - or vim.lsp.get_active_clients({ name = 'nuwiki' }) - if clients and #clients > 0 then - vim.health.ok('LSP client attached (' .. #clients .. ' instance(s))') +local function clients() + return (vim.lsp.get_clients or vim.lsp.get_active_clients)({ name = 'nuwiki' }) +end + +local function lsp_section() + local cs = clients() + if cs and #cs > 0 then + vim.health.ok('LSP client attached (' .. #cs .. ' instance(s))') + local cl = cs[1] + local cmds = vim.tbl_get( + cl, + 'server_capabilities', + 'executeCommandProvider', + 'commands' + ) or {} + if #cmds > 0 then + vim.health.ok('server advertises ' .. #cmds .. ' executeCommand entries') + else + vim.health.info('server has not yet reported executeCommand entries') + end + local folding = vim.tbl_get(cl, 'server_capabilities', 'foldingRangeProvider') + if folding then + vim.health.ok('server supports textDocument/foldingRange') + else + vim.health.info('server has not advertised foldingRange capability') + end else vim.health.info('LSP client not yet attached — open a .wiki buffer') end end +local function html_section(opts) + local html_path = (opts.wikis and opts.wikis[1] and opts.wikis[1].html_path) + or (opts.wiki_root and (vim.fn.expand(opts.wiki_root) .. '/_html')) + if not html_path or html_path == '' then + return + end + if vim.fn.isdirectory(html_path) == 1 then + -- Probe writability with `filewritable` on the directory. + if vim.fn.filewritable(html_path) == 2 then + vim.health.ok('html output dir writable: ' .. html_path) + else + vim.health.warn('html output dir exists but not writable: ' .. html_path) + end + else + vim.health.info( + 'html output dir does not exist yet (created on first export): ' .. html_path + ) + end +end + +local function mappings_section(opts) + if opts.mappings and opts.mappings.enabled == false then + vim.health.info('default keymaps disabled by user config') + return + end + vim.health.ok('default keymaps enabled (subgroups: ' + .. table.concat(vim.tbl_keys(opts.mappings or {}), ',') .. ')') +end + +function M.check() + local config = require('nuwiki.config') + local opts = config.options or config.defaults + + vim.health.start('nuwiki') + bin_section() + wiki_root_section(opts) + filetype_section() + lsp_section() + html_section(opts) + mappings_section(opts) +end + return M diff --git a/lua/nuwiki/keymaps.lua b/lua/nuwiki/keymaps.lua new file mode 100644 index 0000000..0c62c96 --- /dev/null +++ b/lua/nuwiki/keymaps.lua @@ -0,0 +1,73 @@ +-- lua/nuwiki/keymaps.lua — buffer-local key bindings for `.wiki` buffers. +-- +-- Opt-in via `mappings.enabled = true` (default). Subgroups can be +-- disabled independently — see `defaults.lua` for the schema. +-- +-- Mappings here cover the subset of vimwiki's defaults whose server-side +-- commands are implemented. Anything pointing at a SPEC §13.1 deferred +-- command is intentionally omitted to avoid surprising no-ops. + +local M = {} + +local function map(mode, lhs, rhs, opts, bufnr) + opts = vim.tbl_extend('force', { buffer = bufnr or 0, silent = true }, opts or {}) + vim.keymap.set(mode, lhs, rhs, opts) +end + +--- Register all keymaps for the given buffer. Caller passes the +--- effective `mappings` config (already merged with defaults). +function M.attach(bufnr, mappings) + local cmd = require('nuwiki.commands') + + if not mappings or mappings.enabled == false then + return + end + + -- ===== Wiki navigation ===== + map('n', 'ww', cmd.diary_today, { desc = 'nuwiki: open today\'s diary' }, bufnr) + map('n', 'wt', cmd.diary_today, { desc = 'nuwiki: open today\'s diary' }, bufnr) + map('n', 'ws', cmd.wiki_ui_select, { desc = 'nuwiki: pick wiki' }, bufnr) + map('n', 'wi', cmd.diary_index, { desc = 'nuwiki: open diary index' }, bufnr) + map('n', 'ww', cmd.diary_today, { desc = 'nuwiki: today' }, bufnr) + map('n', 'wy', cmd.diary_yesterday, { desc = 'nuwiki: yesterday' }, bufnr) + map('n', 'wt', cmd.diary_tomorrow, { desc = 'nuwiki: tomorrow' }, bufnr) + map('n', 'wd', cmd.delete_file, { desc = 'nuwiki: delete page' }, bufnr) + map('n', 'wr', cmd.rename_file, { desc = 'nuwiki: rename page' }, bufnr) + map('n', 'wn', cmd.wiki_goto_page, { desc = 'nuwiki: goto page' }, bufnr) + + -- Link follow / nav + map('n', '', vim.lsp.buf.definition, { desc = 'nuwiki: follow link' }, bufnr) + map('n', '', '', { desc = 'nuwiki: go back', remap = true }, bufnr) + + -- ===== Diary nav ===== + if mappings.diary ~= false then + map('n', '', cmd.diary_next, { desc = 'nuwiki: next diary entry' }, bufnr) + map('n', '', cmd.diary_prev, { desc = 'nuwiki: previous diary entry' }, bufnr) + end + + -- ===== List editing ===== + if mappings.list_editing ~= false then + map('n', '', cmd.toggle_list_item, { desc = 'nuwiki: toggle checkbox' }, bufnr) + map('n', 'gnt', cmd.next_task, { desc = 'nuwiki: next unfinished task' }, bufnr) + map('n', 'gln', cmd.cycle_list_item, { desc = 'nuwiki: cycle checkbox' }, bufnr) + map('n', 'glp', cmd.cycle_list_item, { desc = 'nuwiki: cycle checkbox' }, bufnr) + map('n', 'glx', cmd.reject_list_item, { desc = 'nuwiki: reject checkbox' }, bufnr) + end + + -- ===== Header level ===== + if mappings.header_nav ~= false then + -- `=`/`-` is the vimwiki default but collides with Vim's built-in + -- reformat operator. Use `g=` / `g-` instead to stay out of the way. + map('n', 'g=', cmd.heading_add_level, { desc = 'nuwiki: heading deeper' }, bufnr) + map('n', 'g-', cmd.heading_remove_level, { desc = 'nuwiki: heading shallower' }, bufnr) + end + + -- ===== HTML export ===== + if mappings.html_export ~= false then + map('n', 'wh', cmd.export_current, { desc = 'nuwiki: export to HTML' }, bufnr) + map('n', 'whh', cmd.export_browse, { desc = 'nuwiki: export + browse' }, bufnr) + map('n', 'wha', cmd.export_all, { desc = 'nuwiki: export all' }, bufnr) + end +end + +return M diff --git a/lua/nuwiki/textobjects.lua b/lua/nuwiki/textobjects.lua new file mode 100644 index 0000000..3fa7150 --- /dev/null +++ b/lua/nuwiki/textobjects.lua @@ -0,0 +1,64 @@ +-- lua/nuwiki/textobjects.lua — text objects (Phase 19). +-- +-- Currently ships `ah` / `ih` (around / inside heading). The remaining +-- four objects from SPEC §12.10 (`aH`/`iH`, `a\`/`i\`, `ac`/`ic`, +-- `al`/`il`) need the table/list rewriters from §13.1 to be fully +-- useful — they ship together with those commands in a follow-up. + +local M = {} + +local function heading_level(line) + local lead = line and line:match('^%s*(=+)%s') + if not lead then return 0 end + local trail = line:match('%s(=+)%s*$') + if not trail or #trail ~= #lead then return 0 end + return #lead +end + +--- Find the start and end line of the heading block containing `lnum`. +--- A heading block runs from the heading line to the line before the +--- next heading of same-or-higher level (or end-of-buffer). +local function heading_block(lnum) + local total = vim.api.nvim_buf_line_count(0) + -- Walk back to the nearest heading line. + local start + for i = lnum, 1, -1 do + local lvl = heading_level(vim.fn.getline(i)) + if lvl > 0 then + start = i + break + end + end + if not start then return nil end + local level = heading_level(vim.fn.getline(start)) + -- Walk forward for the next heading of same-or-higher level. + local stop = total + for i = start + 1, total do + local lvl = heading_level(vim.fn.getline(i)) + if lvl > 0 and lvl <= level then + stop = i - 1 + break + end + end + return start, stop +end + +--- Operator-pending entry point. `inner` strips the heading line itself. +function M.select(inner) + local lnum = vim.fn.line('.') + local s, e = heading_block(lnum) + if not s then return end + if inner then + s = s + 1 + end + if e < s then return end + vim.cmd(string.format('normal! %dG0V%dG$', s, e)) +end + +function M.attach(bufnr) + local opts = { buffer = bufnr or 0, silent = true } + vim.keymap.set({ 'o', 'x' }, 'ah', function() M.select(false) end, opts) + vim.keymap.set({ 'o', 'x' }, 'ih', function() M.select(true) end, opts) +end + +return M