ad1b55a401
SPEC audit of Phases 12–13 surfaced two gaps. Closing them here so the
v1.1 command surface and capability advertisement match what §12.3 and
§12.4 promise.
Phase 12 — tag commands (§12.3):
- `nuwiki.tags.search` — case-insensitive substring filter over the
workspace tag index. Returns `[{ name, uri, range, scope, page }]`
where `scope` is one of `file` / `heading` / `standalone`.
- `nuwiki.tags.generateLinks` — generates/refreshes a tagged-pages
section. Two modes:
- `{ uri, tag }` → `= Tag: <name> =` + flat list of `[[Page]]`
entries for every page tagged with `<name>`.
- `{ uri }` (no tag) → `= Tags =` umbrella section with `== <tag>
==` subgroups, mirroring `:VimwikiGenerateTagLinks` (no arg).
Idempotent: re-runs replace the existing section by
case-insensitive heading match.
- `nuwiki.tags.rebuild` — synchronous full re-walk of the wiki root.
Drops index entries for pages whose files no longer exist on disk,
re-parses every `.wiki`, and returns `{ pages, files_seen,
stale_removed }`. Useful for cleaning up after out-of-band edits
the LSP didn't see.
Ops layer:
- `tag_hits(index, query) → Vec<TagHit>` — pure filter, sorted by
(name, page, line).
- `tag_pages_snapshot(index) → BTreeMap<tag, [pages]>` — taken under
the read lock so the dispatcher can release the lock before
building the edit.
- `build_tag_links_text` / `tag_links_edit` — text-level rendering
and the WorkspaceEdit producer.
Phase 13 — file-operations capability (§12.4):
- `ServerCapabilities.workspace.file_operations` now advertises
`did_rename` + `did_delete` with filters scoped to `**/*.wiki`
and `**/*.md`. (will_* deferred — those return a WorkspaceEdit
and we'd want to wire them into the rename pipeline before
promising support.)
- `did_rename_files` handler — moves the live DocumentState forward
to the new URI when the file was open, then refreshes the index
(remove old URI, upsert new via read_or_open).
- `did_delete_files` handler — drops both DocumentState and index
entry so backlink lookups don't surface dangling source URIs.
Tests: 15 new in `phase17_backfill.rs` covering tag hit filtering &
ordering, snapshot dedup, single-tag and full-index render shapes,
edit replace-vs-insert flow, JSON shape of `TagHit`, and the COMMANDS
list. Total 324 tests pass; fmt + clippy clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
236 lines
7.6 KiB
Rust
236 lines
7.6 KiB
Rust
//! Backfill tests for items missed in Phases 12–13:
|
||
//! - `nuwiki.tags.search`, `nuwiki.tags.generateLinks`, `nuwiki.tags.rebuild`
|
||
//! - `workspace/fileOperations` capability advertisement
|
||
//!
|
||
//! These tests drive the pure ops directly; the live-handler paths
|
||
//! (rebuild walking the filesystem, did_rename re-reading from disk) are
|
||
//! covered by the existing Phase 11 closed-doc loader tests.
|
||
|
||
use std::collections::BTreeMap;
|
||
use std::path::PathBuf;
|
||
|
||
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
|
||
use nuwiki_core::syntax::SyntaxPlugin;
|
||
use nuwiki_lsp::commands::ops;
|
||
use nuwiki_lsp::index::WorkspaceIndex;
|
||
use tower_lsp::lsp_types::Url;
|
||
|
||
fn parse(src: &str) -> nuwiki_core::ast::DocumentNode {
|
||
VimwikiSyntax::new().parse(src)
|
||
}
|
||
|
||
fn build_index(root: &str, pages: &[(&str, &str)]) -> WorkspaceIndex {
|
||
let mut idx = WorkspaceIndex::new(Some(PathBuf::from(root)));
|
||
for (name, src) in pages {
|
||
let path = format!("{root}/{name}.wiki");
|
||
let uri = Url::from_file_path(&path).unwrap();
|
||
idx.upsert(uri, &parse(src));
|
||
}
|
||
idx
|
||
}
|
||
|
||
// ===== tag_hits =====
|
||
|
||
#[test]
|
||
fn tag_hits_empty_query_returns_every_tag() {
|
||
let idx = build_index(
|
||
"/tmp/th1",
|
||
&[
|
||
("Home", "= Home =\n:work:personal:\n"),
|
||
("Notes", "= Notes =\n:work:\n"),
|
||
],
|
||
);
|
||
let hits = ops::tag_hits(&idx, "");
|
||
let names: Vec<&str> = hits.iter().map(|h| h.name.as_str()).collect();
|
||
assert!(names.contains(&"work"));
|
||
assert!(names.contains(&"personal"));
|
||
// 2 pages tagged with `work`, 1 with `personal` → 3 hits.
|
||
assert_eq!(hits.len(), 3);
|
||
}
|
||
|
||
#[test]
|
||
fn tag_hits_substring_filter_case_insensitive() {
|
||
let idx = build_index(
|
||
"/tmp/th2",
|
||
&[("Page", "= Page =\n:release-2026:RoadMap:\n")],
|
||
);
|
||
let hits = ops::tag_hits(&idx, "RELEASE");
|
||
assert_eq!(hits.len(), 1);
|
||
assert_eq!(hits[0].name, "release-2026");
|
||
}
|
||
|
||
#[test]
|
||
fn tag_hits_orders_by_name_then_page() {
|
||
let idx = build_index(
|
||
"/tmp/th3",
|
||
&[
|
||
("Beta", "= Beta =\n:zeta:\n"),
|
||
("Alpha", "= Alpha =\n:alpha:\n"),
|
||
("Gamma", "= Gamma =\n:alpha:\n"),
|
||
],
|
||
);
|
||
let hits = ops::tag_hits(&idx, "");
|
||
let pairs: Vec<(String, String)> = hits
|
||
.iter()
|
||
.map(|h| (h.name.clone(), h.page.clone()))
|
||
.collect();
|
||
// alpha tag comes first; within it, Alpha page before Gamma; then zeta/Beta.
|
||
assert_eq!(pairs[0], ("alpha".into(), "Alpha".into()));
|
||
assert_eq!(pairs[1], ("alpha".into(), "Gamma".into()));
|
||
assert_eq!(pairs[2], ("zeta".into(), "Beta".into()));
|
||
}
|
||
|
||
#[test]
|
||
fn tag_hits_serializes_to_expected_json_shape() {
|
||
let idx = build_index("/tmp/th4", &[("Page", "= Page =\n:release:\n")]);
|
||
let hits = ops::tag_hits(&idx, "");
|
||
let v = serde_json::to_value(&hits).unwrap();
|
||
let row = &v[0];
|
||
assert_eq!(row["name"], "release");
|
||
assert!(row["uri"].is_string());
|
||
assert!(row["range"]["start"]["line"].is_number());
|
||
assert!(row["page"].is_string());
|
||
// scope is one of the three string variants.
|
||
let scope = row["scope"].as_str().unwrap();
|
||
assert!(
|
||
["file", "heading", "standalone"].contains(&scope),
|
||
"{scope}"
|
||
);
|
||
}
|
||
|
||
// ===== tag_pages_snapshot =====
|
||
|
||
#[test]
|
||
fn tag_pages_snapshot_deduplicates_pages_per_tag() {
|
||
// Same page has the tag in both file scope and heading scope —
|
||
// dedup so the rendered list doesn't show it twice.
|
||
let idx = build_index(
|
||
"/tmp/ts1",
|
||
&[("Home", "= Home =\n:release:\n== Section ==\n:release:\n")],
|
||
);
|
||
let snap = ops::tag_pages_snapshot(&idx);
|
||
assert_eq!(snap.get("release").unwrap().len(), 1);
|
||
assert_eq!(snap["release"][0], "Home");
|
||
}
|
||
|
||
#[test]
|
||
fn tag_pages_snapshot_sorts_pages_alphabetically() {
|
||
let idx = build_index(
|
||
"/tmp/ts2",
|
||
&[
|
||
("Charlie", "= Charlie =\n:topic:\n"),
|
||
("Alpha", "= Alpha =\n:topic:\n"),
|
||
("Bravo", "= Bravo =\n:topic:\n"),
|
||
],
|
||
);
|
||
let snap = ops::tag_pages_snapshot(&idx);
|
||
assert_eq!(snap["topic"], vec!["Alpha", "Bravo", "Charlie"]);
|
||
}
|
||
|
||
// ===== build_tag_links_text =====
|
||
|
||
#[test]
|
||
fn build_tag_links_for_single_tag() {
|
||
let mut snap = BTreeMap::new();
|
||
snap.insert("release".to_string(), vec!["Alpha".into(), "Beta".into()]);
|
||
let out = ops::build_tag_links_text(&snap, Some("release")).unwrap();
|
||
let lines: Vec<&str> = out.lines().collect();
|
||
assert_eq!(lines[0], "= Tag: release =");
|
||
assert_eq!(lines[1], "- [[Alpha]]");
|
||
assert_eq!(lines[2], "- [[Beta]]");
|
||
}
|
||
|
||
#[test]
|
||
fn build_tag_links_for_missing_tag_returns_none() {
|
||
let snap = BTreeMap::new();
|
||
assert!(ops::build_tag_links_text(&snap, Some("ghost")).is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn build_tag_links_full_index_groups_by_tag() {
|
||
let mut snap = BTreeMap::new();
|
||
snap.insert("a".to_string(), vec!["P1".into()]);
|
||
snap.insert("b".to_string(), vec!["P2".into(), "P3".into()]);
|
||
let out = ops::build_tag_links_text(&snap, None).unwrap();
|
||
assert!(out.starts_with("= Tags =\n"));
|
||
assert!(out.contains("== a =="));
|
||
assert!(out.contains("== b =="));
|
||
assert!(out.contains("- [[P1]]"));
|
||
assert!(out.contains("- [[P2]]"));
|
||
assert!(out.contains("- [[P3]]"));
|
||
}
|
||
|
||
#[test]
|
||
fn build_tag_links_full_index_returns_none_when_no_tags() {
|
||
let snap = BTreeMap::new();
|
||
assert!(ops::build_tag_links_text(&snap, None).is_none());
|
||
}
|
||
|
||
// ===== tag_links_edit =====
|
||
|
||
#[test]
|
||
fn tag_links_edit_inserts_when_section_missing() {
|
||
let src = "Some content.\n";
|
||
let ast = parse(src);
|
||
let uri = Url::parse("file:///tmp/p.wiki").unwrap();
|
||
let mut snap = BTreeMap::new();
|
||
snap.insert("release".to_string(), vec!["Alpha".into()]);
|
||
let edit =
|
||
ops::tag_links_edit(src, &ast, &uri, Some("release"), &snap, true).expect("got an edit");
|
||
let te = &edit.changes.unwrap()[&uri][0];
|
||
// Insertion at start.
|
||
assert_eq!(te.range.start, te.range.end);
|
||
assert!(te.new_text.contains("= Tag: release ="));
|
||
assert!(te.new_text.contains("[[Alpha]]"));
|
||
}
|
||
|
||
#[test]
|
||
fn tag_links_edit_replaces_existing_section() {
|
||
let src = "= Tag: release =\n- [[Stale]]\n\n= Other =\n";
|
||
let ast = parse(src);
|
||
let uri = Url::parse("file:///tmp/p.wiki").unwrap();
|
||
let mut snap = BTreeMap::new();
|
||
snap.insert("release".to_string(), vec!["Fresh".into()]);
|
||
let edit = ops::tag_links_edit(src, &ast, &uri, Some("release"), &snap, true).expect("edit");
|
||
let te = &edit.changes.unwrap()[&uri][0];
|
||
assert_eq!(te.range.start.line, 0);
|
||
assert!(te.new_text.contains("[[Fresh]]"));
|
||
assert!(!te.new_text.contains("Stale"));
|
||
}
|
||
|
||
#[test]
|
||
fn tag_links_edit_full_index_replaces_existing_tags_section() {
|
||
let src = "= Tags =\n- [[old]]\n";
|
||
let ast = parse(src);
|
||
let uri = Url::parse("file:///tmp/p.wiki").unwrap();
|
||
let mut snap = BTreeMap::new();
|
||
snap.insert("alpha".to_string(), vec!["P".into()]);
|
||
let edit = ops::tag_links_edit(src, &ast, &uri, None, &snap, true).expect("edit");
|
||
let te = &edit.changes.unwrap()[&uri][0];
|
||
assert!(te.new_text.contains("== alpha =="));
|
||
assert!(!te.new_text.contains("[[old]]"));
|
||
}
|
||
|
||
#[test]
|
||
fn tag_links_edit_returns_none_for_unknown_tag() {
|
||
let src = "hi\n";
|
||
let ast = parse(src);
|
||
let uri = Url::parse("file:///tmp/p.wiki").unwrap();
|
||
let snap = BTreeMap::new();
|
||
assert!(ops::tag_links_edit(src, &ast, &uri, Some("ghost"), &snap, true).is_none());
|
||
}
|
||
|
||
// ===== COMMANDS list contains new entries =====
|
||
|
||
#[test]
|
||
fn commands_list_includes_tag_commands() {
|
||
let names: Vec<&str> = nuwiki_lsp::commands::COMMANDS.to_vec();
|
||
for name in [
|
||
"nuwiki.tags.search",
|
||
"nuwiki.tags.generateLinks",
|
||
"nuwiki.tags.rebuild",
|
||
] {
|
||
assert!(names.contains(&name), "missing: {name}");
|
||
}
|
||
}
|