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:
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user