63e5b6d514
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>
112 lines
3.8 KiB
Rust
112 lines
3.8 KiB
Rust
//! 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);
|
|
}
|