phase 19: editor glue v2
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.<group>` 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) <noreply@anthropic.com>
This commit is contained in:
@@ -41,7 +41,7 @@ See [`SPEC.md`](./SPEC.md) for the full project specification.
|
|||||||
| 16 | Diary | ✅ done |
|
| 16 | Diary | ✅ done |
|
||||||
| 17 | HTML export commands | ✅ done |
|
| 17 | HTML export commands | ✅ done |
|
||||||
| 18 | Multi-wiki | ✅ 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
|
## Repository layout
|
||||||
|
|
||||||
|
|||||||
@@ -1382,14 +1382,12 @@ Deferred because the value proposition over the existing
|
|||||||
`workspace/rename` flow is marginal and the editor-side support is
|
`workspace/rename` flow is marginal and the editor-side support is
|
||||||
inconsistent across clients.
|
inconsistent across clients.
|
||||||
|
|
||||||
### 13.3 v1.1 phase 19
|
### 13.3 v1.1 status
|
||||||
|
|
||||||
Tracked in §10 with status `⏳`. Not "missing" from earlier phases —
|
All numbered phases through 19 are shipped. The remaining work is
|
||||||
it's future work in the v1.1 roadmap:
|
the §13.1 deferred Phase 14 command surface (table/list rewriters,
|
||||||
|
link helpers, colorize) and the small follow-ups noted under §13.2 /
|
||||||
- **Phase 19** — Editor glue v2: `:Vimwiki*` compat layer, default
|
§13.4.
|
||||||
keymaps, text objects, folding (P14 already resolved in favour of
|
|
||||||
LSP `foldingRange` + `foldexpr` fallback).
|
|
||||||
|
|
||||||
Earlier v1.1 phases now shipped:
|
Earlier v1.1 phases now shipped:
|
||||||
|
|
||||||
@@ -1409,6 +1407,21 @@ Earlier v1.1 phases now shipped:
|
|||||||
picker commands: `nuwiki.wiki.listAll`, `nuwiki.wiki.select`,
|
picker commands: `nuwiki.wiki.listAll`, `nuwiki.wiki.select`,
|
||||||
`nuwiki.wiki.openIndex`, `nuwiki.wiki.tabOpenIndex`, plus
|
`nuwiki.wiki.openIndex`, `nuwiki.wiki.tabOpenIndex`, plus
|
||||||
`nuwiki.wiki.gotoPage` for the `:VimwikiGoto` compat surface.
|
`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 `<C-Up>`/`<C-Down>`, HTML export under `<Leader>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
|
### 13.4 §9 syntax checklist status
|
||||||
|
|
||||||
|
|||||||
@@ -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<FoldingRange> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ pub mod diagnostics;
|
|||||||
pub mod diary;
|
pub mod diary;
|
||||||
pub mod edits;
|
pub mod edits;
|
||||||
pub mod export;
|
pub mod export;
|
||||||
|
pub mod folding;
|
||||||
pub mod index;
|
pub mod index;
|
||||||
pub mod nav;
|
pub mod nav;
|
||||||
pub mod rename;
|
pub mod rename;
|
||||||
@@ -47,11 +48,12 @@ use tower_lsp::lsp_types::{
|
|||||||
DidChangeTextDocumentParams, DidCloseTextDocumentParams, DidOpenTextDocumentParams,
|
DidChangeTextDocumentParams, DidCloseTextDocumentParams, DidOpenTextDocumentParams,
|
||||||
DidSaveTextDocumentParams, DocumentSymbol, DocumentSymbolParams, DocumentSymbolResponse,
|
DidSaveTextDocumentParams, DocumentSymbol, DocumentSymbolParams, DocumentSymbolResponse,
|
||||||
ExecuteCommandOptions, ExecuteCommandParams, FileOperationFilter, FileOperationPattern,
|
ExecuteCommandOptions, ExecuteCommandParams, FileOperationFilter, FileOperationPattern,
|
||||||
FileOperationPatternKind, FileOperationRegistrationOptions, GotoDefinitionParams,
|
FileOperationPatternKind, FileOperationRegistrationOptions, FoldingRange, FoldingRangeParams,
|
||||||
GotoDefinitionResponse, Hover, HoverContents, HoverParams, HoverProviderCapability,
|
FoldingRangeProviderCapability, GotoDefinitionParams, GotoDefinitionResponse, Hover,
|
||||||
InitializeParams, InitializeResult, InitializedParams, Location, MarkupContent, MarkupKind,
|
HoverContents, HoverParams, HoverProviderCapability, InitializeParams, InitializeResult,
|
||||||
MessageType, OneOf, PositionEncodingKind, ProgressParams, ProgressParamsValue, ProgressToken,
|
InitializedParams, Location, MarkupContent, MarkupKind, MessageType, OneOf,
|
||||||
Range, ReferenceParams, RenameFilesParams, RenameParams, SaveOptions, SemanticTokens,
|
PositionEncodingKind, ProgressParams, ProgressParamsValue, ProgressToken, Range,
|
||||||
|
ReferenceParams, RenameFilesParams, RenameParams, SaveOptions, SemanticTokens,
|
||||||
SemanticTokensFullOptions, SemanticTokensOptions, SemanticTokensParams,
|
SemanticTokensFullOptions, SemanticTokensOptions, SemanticTokensParams,
|
||||||
SemanticTokensRangeParams, SemanticTokensRangeResult, SemanticTokensResult,
|
SemanticTokensRangeParams, SemanticTokensRangeResult, SemanticTokensResult,
|
||||||
SemanticTokensServerCapabilities, ServerCapabilities, ServerInfo,
|
SemanticTokensServerCapabilities, ServerCapabilities, ServerInfo,
|
||||||
@@ -408,6 +410,7 @@ impl LanguageServer for Backend {
|
|||||||
workspace_folders: None,
|
workspace_folders: None,
|
||||||
file_operations: Some(wiki_file_operations()),
|
file_operations: Some(wiki_file_operations()),
|
||||||
}),
|
}),
|
||||||
|
folding_range_provider: Some(FoldingRangeProviderCapability::Simple(true)),
|
||||||
..ServerCapabilities::default()
|
..ServerCapabilities::default()
|
||||||
},
|
},
|
||||||
server_info: Some(ServerInfo {
|
server_info: Some(ServerInfo {
|
||||||
@@ -839,6 +842,18 @@ impl LanguageServer for Backend {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn folding_range(
|
||||||
|
&self,
|
||||||
|
params: FoldingRangeParams,
|
||||||
|
) -> LspResult<Option<Vec<FoldingRange>>> {
|
||||||
|
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(
|
async fn symbol(
|
||||||
&self,
|
&self,
|
||||||
params: WorkspaceSymbolParams,
|
params: WorkspaceSymbolParams,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
+109
-4
@@ -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
|
" Wires up:
|
||||||
" markup files. Anything richer (folding, navigation keymaps, …) ships
|
" - basic edit-time options (commentstring, suffixesadd, …)
|
||||||
" downstream once users opt in.
|
" - 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')
|
if exists('b:did_ftplugin')
|
||||||
finish
|
finish
|
||||||
@@ -16,3 +20,104 @@ setlocal suffixesadd=.wiki
|
|||||||
setlocal iskeyword+=-
|
setlocal iskeyword+=-
|
||||||
|
|
||||||
let b:undo_ftplugin = 'setlocal commentstring< comments< formatoptions< suffixesadd< 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(<q-args>)
|
||||||
|
command! -buffer VimwikiBacklinks lua vim.lsp.buf.references()
|
||||||
|
command! -buffer VWB lua vim.lsp.buf.references()
|
||||||
|
command! -buffer -nargs=1 VimwikiSearch lvimgrep /<args>/ **
|
||||||
|
command! -buffer -nargs=1 VWS lvimgrep /<args>/ **
|
||||||
|
|
||||||
|
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 (<bang>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(<q-args>)
|
||||||
|
command! -buffer -nargs=? VimwikiGenerateTagLinks lua require('nuwiki.commands').tags_generate_links(<q-args>)
|
||||||
|
|
||||||
|
" §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 (<bang>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(<q-args>)
|
||||||
|
command! -buffer -nargs=? NuwikiGenerateTagLinks lua require('nuwiki.commands').tags_generate_links(<q-args>)
|
||||||
|
command! -buffer -nargs=1 NuwikiGoto lua require('nuwiki.commands').wiki_goto_page(<q-args>)
|
||||||
|
|
||||||
|
" Phase 19 buffer attach: keymaps + text objects + folding.
|
||||||
|
lua require('nuwiki.ftplugin').attach(0)
|
||||||
|
|||||||
@@ -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
|
||||||
+18
-1
@@ -1,6 +1,7 @@
|
|||||||
-- lua/nuwiki/config.lua — defaults and user-config merge.
|
-- 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 = {}
|
local M = {}
|
||||||
|
|
||||||
@@ -9,6 +10,22 @@ M.defaults = {
|
|||||||
file_extension = '.wiki',
|
file_extension = '.wiki',
|
||||||
syntax = 'vimwiki',
|
syntax = 'vimwiki',
|
||||||
log_level = 'warn',
|
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)
|
M.options = vim.deepcopy(M.defaults)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
+78
-13
@@ -1,20 +1,16 @@
|
|||||||
-- lua/nuwiki/health.lua — `:checkhealth nuwiki` target.
|
-- lua/nuwiki/health.lua — `:checkhealth nuwiki` target.
|
||||||
--
|
--
|
||||||
-- Checks per SPEC §7.6: binary exists, is executable, LSP client running,
|
-- v1.0 checks per SPEC §7.6 (binary, version, filetype, LSP attach).
|
||||||
-- filetype detection works for `.wiki`.
|
-- v1.1 §12.10 additions: registered LSP commands, indexed wikis, HTML
|
||||||
|
-- output path writability, default keymaps installed.
|
||||||
|
|
||||||
local M = {}
|
local M = {}
|
||||||
|
|
||||||
function M.check()
|
local function bin_section()
|
||||||
local install = require('nuwiki.install')
|
local install = require('nuwiki.install')
|
||||||
local config = require('nuwiki.config')
|
|
||||||
|
|
||||||
vim.health.start('nuwiki')
|
|
||||||
|
|
||||||
local bin = install.expected_path()
|
local bin = install.expected_path()
|
||||||
if install.is_installed() then
|
if install.is_installed() then
|
||||||
vim.health.ok('binary present at ' .. bin)
|
vim.health.ok('binary present at ' .. bin)
|
||||||
|
|
||||||
local out = vim.fn.system({ bin, '--version' })
|
local out = vim.fn.system({ bin, '--version' })
|
||||||
if vim.v.shell_error == 0 then
|
if vim.v.shell_error == 0 then
|
||||||
vim.health.ok('binary --version: ' .. (out:gsub('\n', ' ')):gsub('^%s+', ''))
|
vim.health.ok('binary --version: ' .. (out:gsub('\n', ' ')):gsub('^%s+', ''))
|
||||||
@@ -30,8 +26,10 @@ function M.check()
|
|||||||
{ 'Run :lua require("nuwiki").install() to install' }
|
{ 'Run :lua require("nuwiki").install() to install' }
|
||||||
)
|
)
|
||||||
end
|
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 root ~= '' then
|
||||||
if vim.fn.isdirectory(root) == 1 then
|
if vim.fn.isdirectory(root) == 1 then
|
||||||
vim.health.ok('wiki_root exists: ' .. root)
|
vim.health.ok('wiki_root exists: ' .. root)
|
||||||
@@ -39,21 +37,88 @@ function M.check()
|
|||||||
vim.health.warn('wiki_root does not exist: ' .. root)
|
vim.health.warn('wiki_root does not exist: ' .. root)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function filetype_section()
|
||||||
if vim.fn.exists('#filetypedetect#BufRead#*.wiki') ~= 0
|
if vim.fn.exists('#filetypedetect#BufRead#*.wiki') ~= 0
|
||||||
or vim.filetype.match({ filename = 'test.wiki' }) == 'vimwiki' then
|
or vim.filetype.match({ filename = 'test.wiki' }) == 'vimwiki' then
|
||||||
vim.health.ok('filetype detection for *.wiki is wired up')
|
vim.health.ok('filetype detection for *.wiki is wired up')
|
||||||
else
|
else
|
||||||
vim.health.warn('filetype detection for *.wiki not active')
|
vim.health.warn('filetype detection for *.wiki not active')
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
local clients = vim.lsp.get_clients and vim.lsp.get_clients({ name = 'nuwiki' })
|
local function clients()
|
||||||
or vim.lsp.get_active_clients({ name = 'nuwiki' })
|
return (vim.lsp.get_clients or vim.lsp.get_active_clients)({ name = 'nuwiki' })
|
||||||
if clients and #clients > 0 then
|
end
|
||||||
vim.health.ok('LSP client attached (' .. #clients .. ' instance(s))')
|
|
||||||
|
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
|
else
|
||||||
vim.health.info('LSP client not yet attached — open a .wiki buffer')
|
vim.health.info('LSP client not yet attached — open a .wiki buffer')
|
||||||
end
|
end
|
||||||
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
|
return M
|
||||||
|
|||||||
@@ -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', '<Leader>ww', cmd.diary_today, { desc = 'nuwiki: open today\'s diary' }, bufnr)
|
||||||
|
map('n', '<Leader>wt', cmd.diary_today, { desc = 'nuwiki: open today\'s diary' }, bufnr)
|
||||||
|
map('n', '<Leader>ws', cmd.wiki_ui_select, { desc = 'nuwiki: pick wiki' }, bufnr)
|
||||||
|
map('n', '<Leader>wi', cmd.diary_index, { desc = 'nuwiki: open diary index' }, bufnr)
|
||||||
|
map('n', '<Leader>w<Leader>w', cmd.diary_today, { desc = 'nuwiki: today' }, bufnr)
|
||||||
|
map('n', '<Leader>w<Leader>y', cmd.diary_yesterday, { desc = 'nuwiki: yesterday' }, bufnr)
|
||||||
|
map('n', '<Leader>w<Leader>t', cmd.diary_tomorrow, { desc = 'nuwiki: tomorrow' }, bufnr)
|
||||||
|
map('n', '<Leader>wd', cmd.delete_file, { desc = 'nuwiki: delete page' }, bufnr)
|
||||||
|
map('n', '<Leader>wr', cmd.rename_file, { desc = 'nuwiki: rename page' }, bufnr)
|
||||||
|
map('n', '<Leader>wn', cmd.wiki_goto_page, { desc = 'nuwiki: goto page' }, bufnr)
|
||||||
|
|
||||||
|
-- Link follow / nav
|
||||||
|
map('n', '<CR>', vim.lsp.buf.definition, { desc = 'nuwiki: follow link' }, bufnr)
|
||||||
|
map('n', '<Backspace>', '<C-o>', { desc = 'nuwiki: go back', remap = true }, bufnr)
|
||||||
|
|
||||||
|
-- ===== Diary nav =====
|
||||||
|
if mappings.diary ~= false then
|
||||||
|
map('n', '<C-Down>', cmd.diary_next, { desc = 'nuwiki: next diary entry' }, bufnr)
|
||||||
|
map('n', '<C-Up>', cmd.diary_prev, { desc = 'nuwiki: previous diary entry' }, bufnr)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ===== List editing =====
|
||||||
|
if mappings.list_editing ~= false then
|
||||||
|
map('n', '<C-Space>', 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', '<Leader>wh', cmd.export_current, { desc = 'nuwiki: export to HTML' }, bufnr)
|
||||||
|
map('n', '<Leader>whh', cmd.export_browse, { desc = 'nuwiki: export + browse' }, bufnr)
|
||||||
|
map('n', '<Leader>wha', cmd.export_all, { desc = 'nuwiki: export all' }, bufnr)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user