Major clean up and improvements to vimwiki compatibility and documentation.
Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
@@ -1,10 +1,10 @@
|
||||
//! `workspace/executeCommand` dispatcher.
|
||||
//!
|
||||
//! Phase 13 introduced the router + `nuwiki.file.delete`. Phase 14 added
|
||||
//! the cheap surgical edits — checkbox toggle/cycle/reject, heading
|
||||
//! promote/demote, "next task" navigation. Phase 15 layers on the
|
||||
//! generation + workspace-query commands: `toc.generate`,
|
||||
//! `links.generate`, `workspace.checkLinks`, `workspace.findOrphans`.
|
||||
//! The router handles `nuwiki.file.delete`, the cheap surgical edits —
|
||||
//! checkbox toggle/cycle/reject, heading promote/demote, "next task"
|
||||
//! navigation — and the generation + workspace-query commands:
|
||||
//! `toc.generate`, `links.generate`, `workspace.checkLinks`,
|
||||
//! `workspace.findOrphans`.
|
||||
//!
|
||||
//! Every command resolves to either a `WorkspaceEdit` (the server then
|
||||
//! calls `apply_edit`) or a JSON `Value` (returned to the client) — see
|
||||
@@ -87,7 +87,17 @@ pub(crate) async fn execute(
|
||||
Ok(list_checkbox(backend, args, ops::toggle_state)?.map(CommandOutcome::Edit))
|
||||
}
|
||||
"nuwiki.list.cycleCheckbox" => {
|
||||
Ok(list_checkbox(backend, args, ops::cycle_state)?.map(CommandOutcome::Edit))
|
||||
let reverse = args
|
||||
.first()
|
||||
.and_then(|v| v.get("reverse"))
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
let mutate = if reverse {
|
||||
ops::cycle_state_back
|
||||
} else {
|
||||
ops::cycle_state
|
||||
};
|
||||
Ok(list_checkbox(backend, args, mutate)?.map(CommandOutcome::Edit))
|
||||
}
|
||||
"nuwiki.list.rejectCheckbox" => {
|
||||
Ok(list_checkbox(backend, args, ops::reject_state)?.map(CommandOutcome::Edit))
|
||||
@@ -653,7 +663,7 @@ fn tags_rebuild(backend: &Backend, args: Vec<Value>) -> Result<Option<Value>, St
|
||||
})))
|
||||
}
|
||||
|
||||
// ===== Phase 18: multi-wiki picker commands =====
|
||||
// ===== multi-wiki picker commands =====
|
||||
|
||||
fn wiki_list_all(backend: &Backend) -> Result<Option<Value>, String> {
|
||||
let wikis = backend.wikis_snapshot();
|
||||
@@ -811,7 +821,7 @@ fn wiki_goto_page(backend: &Backend, args: Vec<Value>) -> Result<Option<Value>,
|
||||
})))
|
||||
}
|
||||
|
||||
// ===== §13.1 Cluster A: list rewriters =====
|
||||
// ===== Cluster A: list rewriters =====
|
||||
|
||||
fn list_remove_done(backend: &Backend, args: Vec<Value>) -> Result<Option<WorkspaceEdit>, String> {
|
||||
let p = parse_optional_uri_arg(args.clone())?;
|
||||
@@ -944,7 +954,7 @@ fn list_change_level(backend: &Backend, args: Vec<Value>) -> Result<Option<Works
|
||||
))
|
||||
}
|
||||
|
||||
// ===== §13.1 Cluster B: table rewriters =====
|
||||
// ===== Cluster B: table rewriters =====
|
||||
|
||||
fn table_insert(_backend: &Backend, args: Vec<Value>) -> Result<Option<WorkspaceEdit>, String> {
|
||||
#[derive(Deserialize)]
|
||||
@@ -1020,7 +1030,7 @@ fn table_move_column(backend: &Backend, args: Vec<Value>) -> Result<Option<Works
|
||||
))
|
||||
}
|
||||
|
||||
// ===== Phase 17: HTML export commands =====
|
||||
// ===== HTML export commands =====
|
||||
|
||||
fn export_current(
|
||||
backend: &Backend,
|
||||
@@ -1233,6 +1243,20 @@ pub mod ops {
|
||||
}
|
||||
}
|
||||
|
||||
/// Exact inverse of [`cycle_state`] — used by the `glp` mapping
|
||||
/// ("cycle the checkbox state backward").
|
||||
pub fn cycle_state_back(current: &str) -> Option<&'static str> {
|
||||
match current {
|
||||
"[ ]" => Some("[X]"),
|
||||
"[X]" => Some("[O]"),
|
||||
"[O]" => Some("[o]"),
|
||||
"[o]" => Some("[.]"),
|
||||
"[.]" => Some("[ ]"),
|
||||
"[-]" => Some("[ ]"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggle the `[-]` "rejected" state.
|
||||
pub fn reject_state(current: &str) -> Option<&'static str> {
|
||||
match current {
|
||||
@@ -1647,7 +1671,7 @@ pub mod ops {
|
||||
None
|
||||
}
|
||||
|
||||
// ===== Phase 15: TOC + Links generation, workspace queries =====
|
||||
// ===== TOC + Links generation, workspace queries =====
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
@@ -1973,7 +1997,7 @@ pub mod ops {
|
||||
/// on the diagnostics module's internals.
|
||||
pub use crate::diagnostics::collect_wiki_links;
|
||||
|
||||
// ===== §13.1 Cluster A: list rewriters =====
|
||||
// ===== Cluster A: list rewriters =====
|
||||
|
||||
use crate::edits::text_edit_delete;
|
||||
use nuwiki_core::ast::ListSymbol;
|
||||
@@ -2095,7 +2119,9 @@ pub mod ops {
|
||||
|
||||
/// Delete every checkbox item whose state is `Done` or `Rejected`,
|
||||
/// cascading into their sublists. When `pos` is `Some`, restrict to
|
||||
/// the list containing `pos`'s line; otherwise sweep the whole doc.
|
||||
/// the contiguous list block containing `pos`'s line (the "current
|
||||
/// list" — including its nested sublists); otherwise sweep the whole
|
||||
/// doc.
|
||||
pub fn remove_done_edit(
|
||||
text: &str,
|
||||
ast: &DocumentNode,
|
||||
@@ -2103,16 +2129,8 @@ pub mod ops {
|
||||
pos: Option<LspPosition>,
|
||||
utf8: bool,
|
||||
) -> Option<WorkspaceEdit> {
|
||||
let restrict_line = pos.map(|p| p.line);
|
||||
let mut victims: Vec<Span> = Vec::new();
|
||||
walk_list_items(&ast.children, &mut |item| {
|
||||
let matches_scope = match restrict_line {
|
||||
None => true,
|
||||
Some(line) => (item.span.start.line..=item.span.end.line).contains(&line),
|
||||
};
|
||||
if !matches_scope {
|
||||
return;
|
||||
}
|
||||
let mut collect = |item: &ListItemNode| {
|
||||
if matches!(
|
||||
item.checkbox,
|
||||
Some(nuwiki_core::ast::CheckboxState::Done)
|
||||
@@ -2120,7 +2138,19 @@ pub mod ops {
|
||||
) {
|
||||
victims.push(extend_to_line_end(text, item.span));
|
||||
}
|
||||
});
|
||||
};
|
||||
match pos {
|
||||
None => walk_list_items(&ast.children, &mut collect),
|
||||
Some(p) => {
|
||||
// "Current list" = the top-level contiguous list block the
|
||||
// cursor sits in. Walk just that block (and its sublists),
|
||||
// leaving done items in sibling lists untouched.
|
||||
let list = find_top_list_at_line(ast, p.line)?;
|
||||
for item in &list.items {
|
||||
walk_list_items_in_item(item, &mut collect);
|
||||
}
|
||||
}
|
||||
}
|
||||
if victims.is_empty() {
|
||||
return None;
|
||||
}
|
||||
@@ -2131,6 +2161,40 @@ pub mod ops {
|
||||
Some(b.build())
|
||||
}
|
||||
|
||||
/// Find the top-level (contiguous) list block whose span contains
|
||||
/// `line`. Unlike [`find_list_at_line`], this does not descend into
|
||||
/// sublists — it returns the outermost list so callers operate on the
|
||||
/// whole list block the cursor belongs to.
|
||||
fn find_top_list_at_line(ast: &DocumentNode, line: u32) -> Option<&ListNode> {
|
||||
for block in &ast.children {
|
||||
if let Some(list) = find_top_list_in_block(block, line) {
|
||||
return Some(list);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn find_top_list_in_block(block: &BlockNode, line: u32) -> Option<&ListNode> {
|
||||
match block {
|
||||
BlockNode::List(list) => {
|
||||
if (list.span.start.line..=list.span.end.line).contains(&line) {
|
||||
Some(list)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
BlockNode::Blockquote(BlockquoteNode { children, .. }) => {
|
||||
for c in children {
|
||||
if let Some(l) = find_top_list_in_block(c, line) {
|
||||
return Some(l);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Extend a span to include the trailing newline (so deletion removes
|
||||
/// the empty line that would otherwise be left behind).
|
||||
fn extend_to_line_end(text: &str, span: Span) -> Span {
|
||||
@@ -2480,7 +2544,7 @@ pub mod ops {
|
||||
Some((span, marker_str, i))
|
||||
}
|
||||
|
||||
// ===== §13.1 Cluster B: table rewriters =====
|
||||
// ===== Cluster B: table rewriters =====
|
||||
|
||||
use nuwiki_core::ast::{TableNode, TableRowNode};
|
||||
|
||||
@@ -2738,7 +2802,7 @@ pub mod ops {
|
||||
Some(out)
|
||||
}
|
||||
|
||||
// ===== Phase 16: diary helpers =====
|
||||
// ===== diary helpers =====
|
||||
|
||||
use crate::edits::op_create;
|
||||
|
||||
@@ -2792,10 +2856,10 @@ pub mod ops {
|
||||
b.build()
|
||||
}
|
||||
|
||||
// ===== Tag ops (Phase 12 commands) =====
|
||||
// ===== Tag ops =====
|
||||
//
|
||||
// SPEC §12.3 calls for three tag-driven commands beyond the indexing
|
||||
// that Phase 12 already shipped. `tag_hits` powers `nuwiki.tags.search`;
|
||||
// Three tag-driven commands beyond the indexing itself.
|
||||
// `tag_hits` powers `nuwiki.tags.search`;
|
||||
// `tag_links_edit` powers `nuwiki.tags.generateLinks`; the rebuild
|
||||
// command is a direct re-walk of the wiki root in the dispatcher.
|
||||
|
||||
@@ -2984,7 +3048,7 @@ pub mod ops {
|
||||
}
|
||||
}
|
||||
|
||||
/// Disk-touching helpers for the Phase 17 `nuwiki.export.*` commands.
|
||||
/// Disk-touching helpers for the `nuwiki.export.*` commands.
|
||||
/// Kept out of `ops` because everything here is side-effecting — pure
|
||||
/// rendering / templating lives in [`crate::export`].
|
||||
pub mod export_ops {
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
//! Server-side runtime config.
|
||||
//!
|
||||
//! Populated from `InitializeParams.initialization_options` at boot and
|
||||
//! refreshed via `workspace/didChangeConfiguration` (P18). v1.0 single-wiki
|
||||
//! shapes are accepted and desugared into the v1.1 `wikis = [...]` form so
|
||||
//! existing setups keep working (P16).
|
||||
//! refreshed via `workspace/didChangeConfiguration`. Legacy single-wiki
|
||||
//! shapes are accepted and desugared into the `wikis = [...]` form so
|
||||
//! existing setups keep working.
|
||||
//!
|
||||
//! Only the fields actually consumed in Phase 11 land here. Phases 13–19
|
||||
//! extend `WikiConfig` (diary path, html path, template options, …) and
|
||||
//! the top-level `Config` (diagnostic severity, mappings opt-in, …) as
|
||||
//! they need them.
|
||||
//! `WikiConfig` (diary path, html path, template options, …) and the
|
||||
//! top-level `Config` (diagnostic severity, mappings opt-in, …) carry the
|
||||
//! fields actually consumed by the server.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
@@ -39,9 +38,8 @@ pub struct WikiConfig {
|
||||
/// The full path is `<root>/<diary_rel_path>/<diary_index>.wiki`.
|
||||
pub diary_index: String,
|
||||
/// `daily`/`weekly`/`monthly`/`yearly`. Picks the date format the
|
||||
/// diary commands use. v1.1 ships `daily`; the others fall through
|
||||
/// to vimwiki-style names but the commands are no-ops until
|
||||
/// Cluster 4 lands.
|
||||
/// diary commands use. `daily` is supported; the others fall through
|
||||
/// to vimwiki-style names but the commands are no-ops for them.
|
||||
pub diary_frequency: String,
|
||||
/// First day of the week (`monday`..`sunday`). Used by weekly
|
||||
/// diary entry naming.
|
||||
@@ -74,7 +72,7 @@ pub struct WikiConfig {
|
||||
pub nested_syntaxes: std::collections::HashMap<String, String>,
|
||||
/// Rebuild the page's TOC on save when set.
|
||||
pub auto_toc: bool,
|
||||
/// Phase 17 HTML export options. See SPEC §12.8.
|
||||
/// HTML export options.
|
||||
pub html: HtmlConfig,
|
||||
}
|
||||
|
||||
@@ -104,7 +102,7 @@ pub struct HtmlConfig {
|
||||
/// to skip during `allToHtml*`.
|
||||
pub exclude_files: Vec<String>,
|
||||
/// `color_dic`: vimwiki colour-tag name → CSS value mapping used
|
||||
/// by the HTML renderer (SPEC §12.11). Empty by default; the
|
||||
/// by the HTML renderer. Empty by default; the
|
||||
/// renderer falls back to `class="color-<name>"` when a name
|
||||
/// isn't in the dict.
|
||||
pub color_dic: std::collections::HashMap<String, String>,
|
||||
@@ -130,7 +128,7 @@ impl Default for HtmlConfig {
|
||||
impl HtmlConfig {
|
||||
/// Synthesise default paths under `root` when the user hasn't
|
||||
/// specified explicit ones. `html_path` defaults to `<root>/_html`
|
||||
/// and `template_path` to `<root>/_templates` per SPEC §12.8.
|
||||
/// and `template_path` to `<root>/_templates`.
|
||||
pub fn from_root(root: &Path) -> Self {
|
||||
Self {
|
||||
html_path: root.join("_html"),
|
||||
@@ -284,7 +282,7 @@ fn default_links_space_char() -> String {
|
||||
" ".to_string()
|
||||
}
|
||||
|
||||
/// Fill in the v1.1 per-wiki keys that none of the constructors care
|
||||
/// Fill in the per-wiki keys that none of the constructors care
|
||||
/// about. Centralising the defaults here keeps `empty()`, `from_root()`,
|
||||
/// the legacy single-wiki path, and `From<RawWiki>` in sync.
|
||||
fn wiki_defaults() -> WikiDefaults {
|
||||
@@ -329,9 +327,9 @@ impl Config {
|
||||
/// Build a `Config` from `InitializeParams`.
|
||||
///
|
||||
/// Priority for the wikis list:
|
||||
/// 1. `initialization_options.wikis = [...]` (v1.1 shape)
|
||||
/// 1. `initialization_options.wikis = [...]` (multi-wiki shape)
|
||||
/// 2. `initialization_options.wiki_root + file_extension + syntax`
|
||||
/// (v1.0 single-wiki shape, P16)
|
||||
/// (legacy single-wiki shape)
|
||||
/// 3. `workspace_folders[0]` if present
|
||||
/// 4. deprecated `root_uri` if present
|
||||
/// 5. empty list (server stays alive but won't index anything)
|
||||
@@ -445,7 +443,7 @@ mod opt_bool_or_int {
|
||||
#[serde(default, rename_all = "snake_case")]
|
||||
struct InitOptions {
|
||||
wikis: Option<Vec<RawWiki>>,
|
||||
// v1.0 single-wiki compat fields
|
||||
// legacy single-wiki compat fields
|
||||
wiki_root: Option<String>,
|
||||
file_extension: Option<String>,
|
||||
syntax: Option<String>,
|
||||
@@ -466,7 +464,7 @@ struct RawWiki {
|
||||
diary_rel_path: Option<String>,
|
||||
#[serde(default)]
|
||||
diary_index: Option<String>,
|
||||
// Phase 17 HTML keys — all optional, fall back to per-root defaults.
|
||||
// HTML keys — all optional, fall back to per-root defaults.
|
||||
#[serde(default)]
|
||||
html_path: Option<String>,
|
||||
#[serde(default)]
|
||||
@@ -487,7 +485,7 @@ struct RawWiki {
|
||||
exclude_files: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
color_dic: Option<std::collections::HashMap<String, String>>,
|
||||
// v1.1 per-wiki keys mirroring vimwiki globals. All optional so
|
||||
// per-wiki keys mirroring vimwiki globals. All optional so
|
||||
// existing single-wiki configs migrate without touching anything.
|
||||
#[serde(default)]
|
||||
index: Option<String>,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Diagnostic sources beyond parse-time `ErrorNode`s.
|
||||
//!
|
||||
//! Phase 15 lands the `nuwiki.link` source — broken-link warnings. Walks
|
||||
//! The `nuwiki.link` source emits broken-link warnings. Walks
|
||||
//! every `WikiLinkNode` in the AST and emits one diagnostic per:
|
||||
//! - `Wiki` target that doesn't resolve to a page in the workspace index,
|
||||
//! - `Wiki`/`AnchorOnly` target with an anchor that matches neither a
|
||||
@@ -8,7 +8,7 @@
|
||||
//! - `File`/`Local` target whose resolved path doesn't exist on disk.
|
||||
//!
|
||||
//! Interwiki, Diary, Raw, and external links are not diagnosed —
|
||||
//! interwiki resolution is Phase 18's job, diary is Phase 16's, and we
|
||||
//! interwiki resolution and diary handling live elsewhere, and we
|
||||
//! don't fetch URLs.
|
||||
|
||||
use std::path::PathBuf;
|
||||
@@ -332,8 +332,7 @@ fn resolve_file_path(
|
||||
index.root.as_ref().map(|r| r.join(&candidate))
|
||||
}
|
||||
|
||||
/// `nuwiki.link` diagnostics for a single document. Phase 11 left this as
|
||||
/// a stub; Phase 15 fills it in.
|
||||
/// `nuwiki.link` diagnostics for a single document.
|
||||
pub fn link_health(
|
||||
ast: &DocumentNode,
|
||||
text: &str,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Diary subsystem (Phase 16) — pure helpers used by the
|
||||
//! Diary subsystem — pure helpers used by the
|
||||
//! `nuwiki.diary.*` `executeCommand` handlers.
|
||||
//!
|
||||
//! All path/URI math lives here so the command dispatcher stays a thin
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Helpers for building `WorkspaceEdit`s.
|
||||
//!
|
||||
//! Phase 13+ (rename, list/table edits, link/TOC generation) all return
|
||||
//! Rename, list/table edits, and link/TOC generation all return
|
||||
//! `WorkspaceEdit`. The builder centralises the byte-offset → LSP-position
|
||||
//! conversion (UTF-8 or UTF-16, matching whatever was negotiated in
|
||||
//! `initialize`) so each call site doesn't have to redo it.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! HTML export — pure helpers shared by the Phase 17 `nuwiki.export.*`
|
||||
//! HTML export — pure helpers shared by the `nuwiki.export.*`
|
||||
//! command handlers. Side-effecting work (reading the template from
|
||||
//! disk, writing the rendered HTML, copying CSS) lives in `commands.rs`
|
||||
//! since the dispatcher is the one with a `&Backend` and async context.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! `textDocument/foldingRange` provider — SPEC §12.10 / P14.
|
||||
//! `textDocument/foldingRange` provider.
|
||||
//!
|
||||
//! Strategy: one fold per heading (line → line before the next heading
|
||||
//! of same-or-higher level) plus one fold per top-level list block.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Workspace index — the in-memory model of every `.wiki` page nuwiki
|
||||
//! has seen, used to power Phase 8 nav features.
|
||||
//! has seen, used to power nav features.
|
||||
//!
|
||||
//! Per P8 (SPEC §11), the initial scan runs as a background tokio task so
|
||||
//! The initial scan runs as a background tokio task so
|
||||
//! the server stays responsive on startup. Per-document updates land here
|
||||
//! synchronously via `upsert` whenever the LSP backend re-parses on
|
||||
//! `didOpen` / `didChange`.
|
||||
@@ -29,10 +29,10 @@ pub struct IndexedPage {
|
||||
pub title: Option<String>,
|
||||
pub headings: Vec<HeadingInfo>,
|
||||
pub outgoing: Vec<OutgoingLink>,
|
||||
/// Phase 12: every `TagNode` on the page. `tags_by_name` on the
|
||||
/// Every `TagNode` on the page. `tags_by_name` on the
|
||||
/// containing `WorkspaceIndex` is the reverse map.
|
||||
pub tags: Vec<TagInfo>,
|
||||
/// Phase 16: `Some(date)` when this page is a *daily* diary entry —
|
||||
/// `Some(date)` when this page is a *daily* diary entry —
|
||||
/// stem parses as `YYYY-MM-DD`. Equivalent to
|
||||
/// `diary_period.and_then(|p| if let DiaryPeriod::Day(d) = p { Some(d) } else { None })`.
|
||||
/// Kept as its own field for back-compat with prev/next/list callers
|
||||
@@ -97,7 +97,7 @@ pub struct Backlink {
|
||||
}
|
||||
|
||||
/// A tag's location, used by `WorkspaceIndex::tags_for` for cross-page
|
||||
/// `[[Page#tag]]` resolution and `nuwiki.tags.search` (Phase 13).
|
||||
/// `[[Page#tag]]` resolution and `nuwiki.tags.search`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TagOccurrence {
|
||||
pub uri: Url,
|
||||
@@ -108,7 +108,7 @@ pub struct TagOccurrence {
|
||||
#[derive(Debug, Default)]
|
||||
pub struct WorkspaceIndex {
|
||||
pub root: Option<PathBuf>,
|
||||
/// Phase 16: subdir relative to `root` that holds diary entries.
|
||||
/// Subdir relative to `root` that holds diary entries.
|
||||
/// Mirrors `WikiConfig::diary_rel_path`; stored here so `upsert` can
|
||||
/// classify each indexed URI without a back-reference to the config.
|
||||
pub diary_rel_path: Option<String>,
|
||||
@@ -119,9 +119,9 @@ pub struct WorkspaceIndex {
|
||||
pub pages_by_uri: HashMap<Url, IndexedPage>,
|
||||
pub pages_by_name: HashMap<String, Url>,
|
||||
pub backlinks: HashMap<String, Vec<Backlink>>,
|
||||
/// Phase 12: tag name → every occurrence across the workspace. Used by
|
||||
/// the v1.1 nav handlers (tag-as-anchor `definition`, `workspace/symbol`
|
||||
/// inclusion) and the Phase 13 `nuwiki.tags.search` command.
|
||||
/// Tag name → every occurrence across the workspace. Used by
|
||||
/// the nav handlers (tag-as-anchor `definition`, `workspace/symbol`
|
||||
/// inclusion) and the `nuwiki.tags.search` command.
|
||||
pub tags_by_name: HashMap<String, Vec<TagOccurrence>>,
|
||||
}
|
||||
|
||||
@@ -187,7 +187,7 @@ impl WorkspaceIndex {
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 12: maintain the reverse tag map alongside the per-page list.
|
||||
// Maintain the reverse tag map alongside the per-page list.
|
||||
for tag in &page.tags {
|
||||
self.tags_by_name
|
||||
.entry(tag.name.clone())
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
//! LSP protocol bridge for nuwiki.
|
||||
//!
|
||||
//! `tower-lsp` server that maintains a `DocumentStore` (SPEC §6.10) plus a
|
||||
//! workspace-wide index (SPEC §6.10 + P8) and exposes every LSP method
|
||||
//! through Phase 8:
|
||||
//! `tower-lsp` server that maintains a `DocumentStore` plus a
|
||||
//! workspace-wide index and exposes every LSP method:
|
||||
//!
|
||||
//! - `initialize` / `initialized` / `shutdown`
|
||||
//! - `textDocument/didOpen`, `didChange` (full sync), `didClose`
|
||||
@@ -14,12 +13,12 @@
|
||||
//! - `workspace/symbol` — search across indexed headings
|
||||
//!
|
||||
//! Position encoding is negotiated as UTF-8 when the client supports LSP
|
||||
//! 3.17+ encodings (SPEC §6.11). When the client only supports the legacy
|
||||
//! 3.17+ encodings. When the client only supports the legacy
|
||||
//! UTF-16 default, positions get translated through a per-line lookup.
|
||||
//!
|
||||
//! Re-parses are full per change (incremental parsing deferred post-v1).
|
||||
//! Re-parses are full per change (no incremental parsing).
|
||||
//! The workspace scan runs in a background tokio task spawned from
|
||||
//! `initialize` (P8) with `window/workDoneProgress` begin/end events.
|
||||
//! `initialize` with `window/workDoneProgress` begin/end events.
|
||||
|
||||
pub mod commands;
|
||||
pub mod config;
|
||||
@@ -111,8 +110,8 @@ pub async fn run_stdio() -> io::Result<()> {
|
||||
struct DocumentState {
|
||||
text: String,
|
||||
ast: DocumentNode,
|
||||
/// Last version we observed from the client. Held per SPEC §6.10 even
|
||||
/// though the foundation phase doesn't yet need to read it back.
|
||||
/// Last version we observed from the client. Held even
|
||||
/// though nothing reads it back yet.
|
||||
#[allow(dead_code)]
|
||||
version: i32,
|
||||
}
|
||||
@@ -124,8 +123,8 @@ struct Backend {
|
||||
/// True after `initialize` if the client opted into UTF-8 encoding.
|
||||
/// Otherwise we keep the LSP 3.16 default of UTF-16.
|
||||
use_utf8: Arc<AtomicBool>,
|
||||
/// Per-wiki state (P11). Vector holds one entry until Phase 18
|
||||
/// (multi-wiki) lets users add more.
|
||||
/// Per-wiki state. Vector holds one entry until multi-wiki
|
||||
/// support lets users add more.
|
||||
wikis: Arc<RwLock<Vec<Wiki>>>,
|
||||
/// Server config received via `initializationOptions` and refreshed
|
||||
/// via `workspace/didChangeConfiguration` (P18).
|
||||
@@ -151,7 +150,7 @@ impl Backend {
|
||||
wikis.iter().find(|w| w.id == id).cloned()
|
||||
}
|
||||
|
||||
/// Used by Phase 15 workspace-level commands (`checkLinks`, `findOrphans`)
|
||||
/// Used by workspace-level commands (`checkLinks`, `findOrphans`)
|
||||
/// when no URI is supplied — they fall back to the first registered wiki.
|
||||
fn default_wiki(&self) -> Option<Wiki> {
|
||||
let wikis = self.wikis.read().ok()?;
|
||||
@@ -290,7 +289,7 @@ impl Backend {
|
||||
|
||||
/// Return live state if `uri` is open in the editor, otherwise read
|
||||
/// the file from disk and re-parse. The closed-doc path is used by
|
||||
/// cross-document operations (Phase 13 `workspace/rename` and link
|
||||
/// cross-document operations (`workspace/rename` and link
|
||||
/// health) that need accurate spans + text without trusting cached
|
||||
/// `WorkspaceIndex` data.
|
||||
pub async fn read_or_open(&self, uri: &Url) -> io::Result<DocSnapshot> {
|
||||
@@ -318,7 +317,7 @@ impl Backend {
|
||||
}
|
||||
|
||||
async fn update_document(&self, uri: Url, text: String, version: i32) {
|
||||
// For Phase 6 we always treat unknown buffers as vimwiki — if/when
|
||||
// We always treat unknown buffers as vimwiki — if/when
|
||||
// multi-syntax dispatch lands the pick should follow the URI's ext.
|
||||
let plugin = match self.registry.get("vimwiki") {
|
||||
Some(p) => p,
|
||||
@@ -373,7 +372,7 @@ impl Backend {
|
||||
#[tower_lsp::async_trait]
|
||||
impl LanguageServer for Backend {
|
||||
async fn initialize(&self, params: InitializeParams) -> LspResult<InitializeResult> {
|
||||
// SPEC §6.11: prefer UTF-8 if the client advertises support.
|
||||
// Prefer UTF-8 if the client advertises support.
|
||||
let supports_utf8 = params
|
||||
.capabilities
|
||||
.general
|
||||
@@ -404,7 +403,7 @@ impl LanguageServer for Backend {
|
||||
change: Some(TextDocumentSyncKind::FULL),
|
||||
will_save: None,
|
||||
will_save_wait_until: None,
|
||||
// Phase 17 `auto_export` listens on `did_save`. Asking
|
||||
// `auto_export` listens on `did_save`. Asking
|
||||
// for `include_text: false` since the document store
|
||||
// already mirrors the live buffer.
|
||||
save: Some(TextDocumentSyncSaveOptions::SaveOptions(SaveOptions {
|
||||
@@ -502,9 +501,9 @@ impl LanguageServer for Backend {
|
||||
|
||||
async fn did_change_configuration(&self, params: DidChangeConfigurationParams) {
|
||||
// Apply the new settings to `Config`. Re-indexing on root changes
|
||||
// is out of scope for Phase 11; for now we just accept the new
|
||||
// is out of scope here; for now we just accept the new
|
||||
// values so later commands see the right diagnostic settings etc.
|
||||
// Phase 18 (multi-wiki) revisits this with proper rebuild semantics.
|
||||
// Multi-wiki support revisits this with proper rebuild semantics.
|
||||
let mut cfg = self.config.write().expect("config lock poisoned");
|
||||
cfg.apply_change(¶ms.settings);
|
||||
}
|
||||
@@ -530,7 +529,7 @@ impl LanguageServer for Backend {
|
||||
}
|
||||
|
||||
async fn did_rename_files(&self, params: RenameFilesParams) {
|
||||
// Phase 13 advertises this capability so the editor pushes
|
||||
// We advertise this capability so the editor pushes
|
||||
// out-of-band renames (file explorer drags, `:VimwikiRenameFile`
|
||||
// when the client opts to surface the new URI). Update the index
|
||||
// so backlinks/headings track the new URI without waiting for
|
||||
@@ -576,7 +575,7 @@ impl LanguageServer for Backend {
|
||||
}
|
||||
|
||||
async fn did_save(&self, params: DidSaveTextDocumentParams) {
|
||||
// Phase 17: when `auto_export` is set on the resolved wiki, run
|
||||
// When `auto_export` is set on the resolved wiki, run
|
||||
// the equivalent of `nuwiki.export.currentToHtml` after the file
|
||||
// hits disk. Best-effort — failures are logged but do not bubble
|
||||
// to the client. Skips pages with the `%nohtml` directive.
|
||||
@@ -934,7 +933,7 @@ impl LanguageServer for Backend {
|
||||
});
|
||||
}
|
||||
}
|
||||
// Phase 12: surface tags too, named as `:tag:` so editors
|
||||
// Surface tags too, named as `:tag:` so editors
|
||||
// can distinguish them from headings at a glance.
|
||||
for t in &page.tags {
|
||||
if q.is_empty() || t.name.to_lowercase().contains(&q) {
|
||||
@@ -1121,9 +1120,9 @@ fn utf16_column(text: &str, line: u32, byte_col: u32) -> u32 {
|
||||
.sum::<usize>() as u32
|
||||
}
|
||||
|
||||
/// Backward-compat wrapper kept for v1.0 test surface. New call sites
|
||||
/// Backward-compat wrapper kept for the legacy test surface. New call sites
|
||||
/// should use `collect_diagnostics` so they can opt into link-health
|
||||
/// (Phase 15) and other sources.
|
||||
/// and other sources.
|
||||
pub fn ast_diagnostics(ast: &DocumentNode, text: &str, utf8: bool) -> Vec<Diagnostic> {
|
||||
collect_diagnostics(
|
||||
ast,
|
||||
@@ -1136,12 +1135,12 @@ pub fn ast_diagnostics(ast: &DocumentNode, text: &str, utf8: bool) -> Vec<Diagno
|
||||
)
|
||||
}
|
||||
|
||||
/// Composable diagnostic collector (P11 §12.2.4).
|
||||
/// Composable diagnostic collector.
|
||||
///
|
||||
/// Sources, each gated by `cfg`:
|
||||
///
|
||||
/// 1. Parse errors (always on).
|
||||
/// 2. Broken-link warnings (Phase 15) — emitted when an index, source
|
||||
/// 2. Broken-link warnings — emitted when an index, source
|
||||
/// page name, and `link_severity != Off` are all available.
|
||||
pub fn collect_diagnostics(
|
||||
ast: &DocumentNode,
|
||||
@@ -1317,8 +1316,8 @@ fn keyword_str(k: nuwiki_core::ast::Keyword) -> &'static str {
|
||||
|
||||
// ===== Workspace + navigation helpers =====
|
||||
//
|
||||
// `workspace_root_from_params` lived here through Phase 10; Phase 11 lifted
|
||||
// that logic into `Config::from_init_params` (see `config.rs`) so workspace
|
||||
// `workspace_root_from_params` logic now lives in
|
||||
// `Config::from_init_params` (see `config.rs`) so workspace
|
||||
// folders and `root_uri` are handled alongside `initializationOptions`.
|
||||
|
||||
/// Background workspace scan (P8). Walks every `.wiki` file under `root`,
|
||||
@@ -1469,7 +1468,7 @@ fn heading_range_in(idx: &WorkspaceIndex, uri: &Url, anchor: &str, _utf8: bool)
|
||||
if let Some(h) = page.find_heading_by_anchor(anchor) {
|
||||
return Some(span_to_lsp_range_no_text(&h.span));
|
||||
}
|
||||
// Phase 12: tags-as-anchors. `[[Page#tag-name]]` lands on the
|
||||
// Tags-as-anchors. `[[Page#tag-name]]` lands on the
|
||||
// matching `TagNode` on the target page.
|
||||
let t = page.tags.iter().find(|t| t.name == anchor)?;
|
||||
Some(span_to_lsp_range_no_text(&t.span))
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Helpers shared by the Phase 8 navigation handlers (definition,
|
||||
//! Helpers shared by the navigation handlers (definition,
|
||||
//! references, hover, completion).
|
||||
//!
|
||||
//! Two responsibilities:
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
//! `textDocument/rename` — server-side rename with cross-document link
|
||||
//! rewriting.
|
||||
//!
|
||||
//! Phase 13 implementation:
|
||||
//! Implementation:
|
||||
//! 1. Resolve the rename target — cursor on a wikilink renames the linked
|
||||
//! page; otherwise rename the current file.
|
||||
//! 2. Build the new URI from the wiki root + the user-supplied path.
|
||||
//! 3. For every page in the wiki's index with an outgoing link to the
|
||||
//! old page name, read its source via the Phase 11 closed-doc loader
|
||||
//! old page name, read its source via the closed-doc loader
|
||||
//! so cached spans are validated against current text, then emit a
|
||||
//! `TextEdit` that swaps the `[[old]]` for `[[new]]` while preserving
|
||||
//! anchors and descriptions.
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
//! Semantic-token emission for the vimwiki AST.
|
||||
//!
|
||||
//! Token-type scheme settled in P7: custom `vimwiki*` types plus
|
||||
//! `level1`..`level6` + `centered` modifiers (SPEC §11). Default highlight
|
||||
//! groups for these arrive in Phase 9.
|
||||
//! Token-type scheme: custom `vimwiki*` types plus
|
||||
//! `level1`..`level6` + `centered` modifiers.
|
||||
//!
|
||||
//! Pipeline:
|
||||
//!
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
//! `Wiki` aggregate — per-wiki state, even when there's only one.
|
||||
//!
|
||||
//! v1.0 shipped with a single `Arc<RwLock<WorkspaceIndex>>` on `Backend`.
|
||||
//! Phase 11 lifts that into a `Wiki` aggregate so Phase 18 (multi-wiki)
|
||||
//! only changes config shape, not data flow. Phases 12–17 always operate
|
||||
//! on a `Wiki`; in practice the `wikis` `Vec` has one entry until 18.
|
||||
//! The single `Arc<RwLock<WorkspaceIndex>>` on `Backend` is lifted into a
|
||||
//! `Wiki` aggregate so multi-wiki support only changes config shape, not
|
||||
//! data flow. Handlers always operate on a `Wiki`; in practice the `wikis`
|
||||
//! `Vec` has one entry until multi-wiki support lands.
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
@@ -47,6 +47,24 @@ fn cycle_state_walks_progression() {
|
||||
assert_eq!(ops::cycle_state("[-]"), Some("[ ]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cycle_state_back_is_exact_inverse() {
|
||||
// `glp` walks the progression in reverse — every step must undo the
|
||||
// matching `cycle_state` step.
|
||||
assert_eq!(ops::cycle_state_back("[ ]"), Some("[X]"));
|
||||
assert_eq!(ops::cycle_state_back("[X]"), Some("[O]"));
|
||||
assert_eq!(ops::cycle_state_back("[O]"), Some("[o]"));
|
||||
assert_eq!(ops::cycle_state_back("[o]"), Some("[.]"));
|
||||
assert_eq!(ops::cycle_state_back("[.]"), Some("[ ]"));
|
||||
assert_eq!(ops::cycle_state_back("[-]"), Some("[ ]"));
|
||||
// Composing forward then backward returns to the start for every
|
||||
// in-cycle marker.
|
||||
for s in ["[ ]", "[.]", "[o]", "[O]", "[X]"] {
|
||||
let fwd = ops::cycle_state(s).unwrap();
|
||||
assert_eq!(ops::cycle_state_back(fwd), Some(s));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reject_state_toggles_dash_marker() {
|
||||
assert_eq!(ops::reject_state("[ ]"), Some("[-]"));
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
//! §13.1 §13.3 — `color_dic` → `HtmlRenderer` integration.
|
||||
//! `color_dic` → `HtmlRenderer` integration.
|
||||
//!
|
||||
//! Drives the renderer directly so we don't have to spin up the full
|
||||
//! export pipeline. The end-to-end path (export command → renderer
|
||||
//! with `cfg.color_dic` plumbed through) is integration-tested via the
|
||||
//! Phase 17 export tests.
|
||||
//! export tests.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
|
||||
@@ -0,0 +1,532 @@
|
||||
//! Command-coverage suite: at least one behavioural test for every
|
||||
//! documented `:Nuwiki*` / `:Vimwiki*` command (see the COMMANDS section
|
||||
//! of `doc/nuwiki.txt`).
|
||||
//!
|
||||
//! The user-facing commands funnel through `workspace/executeCommand`
|
||||
//! into the Backend dispatcher, or — for follow/backlinks/rename — through
|
||||
//! the standard LSP requests. The Backend itself owns a live `Client` and
|
||||
//! can't be built in an integration test, so each case drives the *pure*
|
||||
//! building-block the command relies on (the same functions the dispatcher
|
||||
//! wraps). This file is organised to mirror the doc's command groups so a
|
||||
//! reader can confirm every command has coverage.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use nuwiki_core::ast::{InlineNode, LinkKind, LinkTarget};
|
||||
use nuwiki_core::date::DiaryDate;
|
||||
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
|
||||
use nuwiki_core::syntax::SyntaxPlugin;
|
||||
use nuwiki_lsp::commands::{export_ops, ops, COMMANDS};
|
||||
use nuwiki_lsp::config::WikiConfig;
|
||||
use nuwiki_lsp::index::WorkspaceIndex;
|
||||
use nuwiki_lsp::nav::find_inline_at;
|
||||
use nuwiki_lsp::rename::{build_new_uri, rewrite_wikilink_target};
|
||||
use nuwiki_lsp::wiki::{build_wikis, resolve_uri_to_wiki};
|
||||
use nuwiki_lsp::{diary, export};
|
||||
use tower_lsp::lsp_types::Url;
|
||||
|
||||
fn parse(src: &str) -> nuwiki_core::ast::DocumentNode {
|
||||
VimwikiSyntax::new().parse(src)
|
||||
}
|
||||
|
||||
fn wiki_cfg(root: &str) -> WikiConfig {
|
||||
WikiConfig::from_root(PathBuf::from(root))
|
||||
}
|
||||
|
||||
/// Build a workspace index rooted at `root` with the given `name -> source`
|
||||
/// pages, mirroring the helper used by the other command suites.
|
||||
fn build_index(root: &str, pages: &[(&str, &str)]) -> WorkspaceIndex {
|
||||
let mut idx = WorkspaceIndex::new(Some(PathBuf::from(root)));
|
||||
for (name, src) in pages {
|
||||
let uri = Url::from_file_path(format!("{root}/{name}.wiki")).unwrap();
|
||||
idx.upsert(uri, &parse(src));
|
||||
}
|
||||
idx
|
||||
}
|
||||
|
||||
fn diary_index(root: &str, dates: &[&str]) -> WorkspaceIndex {
|
||||
let mut idx =
|
||||
WorkspaceIndex::new(Some(PathBuf::from(root))).with_diary_rel_path(Some("diary".into()));
|
||||
for d in dates {
|
||||
let uri = Url::from_file_path(format!("{root}/diary/{d}.wiki")).unwrap();
|
||||
idx.upsert(uri, &parse("= entry =\n"));
|
||||
}
|
||||
idx
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Group 1: Wiki / navigation
|
||||
// =====================================================================
|
||||
|
||||
// :NuwikiIndex — open wiki N's index page.
|
||||
#[test]
|
||||
fn nuwiki_index_resolves_index_page() {
|
||||
let idx = build_index("/wiki", &[("index", "= Home =\n"), ("Other", "= O =\n")]);
|
||||
let page = idx.page_by_name("index").expect("index page indexed");
|
||||
assert!(page.uri.as_str().ends_with("/wiki/index.wiki"));
|
||||
}
|
||||
|
||||
// :NuwikiTabIndex — same target as :NuwikiIndex, opened in a new tab.
|
||||
// The tab-vs-current split is an editor concern; the resolved target is
|
||||
// identical, and both verbs are advertised as executeCommands.
|
||||
#[test]
|
||||
fn nuwiki_tab_index_advertised_alongside_index() {
|
||||
assert!(COMMANDS.contains(&"nuwiki.wiki.openIndex"));
|
||||
assert!(COMMANDS.contains(&"nuwiki.wiki.tabOpenIndex"));
|
||||
}
|
||||
|
||||
// :NuwikiUISelect — pick a wiki from the configured list.
|
||||
#[test]
|
||||
fn nuwiki_ui_select_lists_configured_wikis() {
|
||||
let cfgs = vec![wiki_cfg("/a/personal"), wiki_cfg("/b/work")];
|
||||
let wikis = build_wikis(&cfgs);
|
||||
assert_eq!(wikis.len(), 2);
|
||||
assert_eq!(wikis[0].config.name, "personal");
|
||||
assert_eq!(wikis[1].config.name, "work");
|
||||
|
||||
// Selection by URI picks the wiki that actually owns the file.
|
||||
let uri = Url::from_file_path("/b/work/Page.wiki").unwrap();
|
||||
assert_eq!(resolve_uri_to_wiki(&wikis, &uri), Some(wikis[1].id));
|
||||
}
|
||||
|
||||
// :NuwikiGoto {page} — open `{page}.wiki` by name.
|
||||
#[test]
|
||||
fn nuwiki_goto_opens_page_by_name() {
|
||||
let idx = build_index("/wiki", &[("index", "= H =\n"), ("Recipes", "= R =\n")]);
|
||||
let page = idx
|
||||
.page_by_name("Recipes")
|
||||
.expect("page resolvable by name");
|
||||
assert!(page.uri.as_str().ends_with("/wiki/Recipes.wiki"));
|
||||
assert!(idx.page_by_name("Missing").is_none());
|
||||
}
|
||||
|
||||
// :NuwikiFollowLink — follow the link under the cursor.
|
||||
#[test]
|
||||
fn nuwiki_follow_link_resolves_target_under_cursor() {
|
||||
let idx = build_index(
|
||||
"/wiki",
|
||||
&[("Home", "see [[About]]\n"), ("About", "= A =\n")],
|
||||
);
|
||||
let doc = parse("see [[About]]\n");
|
||||
// Byte col 8 sits inside "[[About]]" (starts at byte 4).
|
||||
let node = find_inline_at(&doc, 0, 8).expect("wikilink under cursor");
|
||||
let InlineNode::WikiLink(w) = node else {
|
||||
panic!("expected a wikilink, got {node:?}");
|
||||
};
|
||||
let about = Url::from_file_path("/wiki/About.wiki").unwrap();
|
||||
assert_eq!(idx.resolve(&w.target, "Home"), Some(&about));
|
||||
}
|
||||
|
||||
// :NuwikiBacklinks — every reference to the current page.
|
||||
#[test]
|
||||
fn nuwiki_backlinks_lists_referrers() {
|
||||
let idx = build_index(
|
||||
"/wiki",
|
||||
&[
|
||||
("Home", "[[About]]\n"),
|
||||
("Contact", "see [[About]] too\n"),
|
||||
("About", "= A =\n"),
|
||||
],
|
||||
);
|
||||
let back = idx.backlinks_for("About");
|
||||
assert_eq!(back.len(), 2);
|
||||
assert!(idx.backlinks_for("Home").is_empty());
|
||||
}
|
||||
|
||||
// :NuwikiNextLink / :NuwikiPrevLink — jump to the next / previous wikilink.
|
||||
// The jump itself is editor-side cursor movement; what the server provides
|
||||
// is the ordered set of links on the page.
|
||||
#[test]
|
||||
fn nuwiki_next_prev_link_walk_links_in_document_order() {
|
||||
let doc = parse("intro [[First]] mid [[Second]] end [[Third]]\n");
|
||||
let links = ops::collect_wiki_links(&doc);
|
||||
let targets: Vec<&str> = links
|
||||
.iter()
|
||||
.filter_map(|l| l.target.path.as_deref())
|
||||
.collect();
|
||||
assert_eq!(targets, vec!["First", "Second", "Third"]);
|
||||
}
|
||||
|
||||
// :NuwikiBaddLink — add the target of the link under the cursor to the
|
||||
// buffer list. The editor :badds whatever URI the link resolves to.
|
||||
#[test]
|
||||
fn nuwiki_badd_link_resolves_target_uri() {
|
||||
let idx = build_index(
|
||||
"/wiki",
|
||||
&[("Home", "[[Notes/Todo]]\n"), ("Notes/Todo", "= T =\n")],
|
||||
);
|
||||
let target = LinkTarget {
|
||||
kind: LinkKind::Wiki,
|
||||
path: Some("Notes/Todo".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let expected = Url::from_file_path("/wiki/Notes/Todo.wiki").unwrap();
|
||||
assert_eq!(idx.resolve(&target, "Home"), Some(&expected));
|
||||
}
|
||||
|
||||
// :NuwikiRenameFile — rename the page and rewrite every inbound link.
|
||||
#[test]
|
||||
fn nuwiki_rename_file_builds_uri_and_rewrites_links() {
|
||||
let new_uri = build_new_uri(&PathBuf::from("/wiki"), "New Name", ".wiki").unwrap();
|
||||
assert!(new_uri.as_str().ends_with("/wiki/New%20Name.wiki"));
|
||||
|
||||
assert_eq!(
|
||||
rewrite_wikilink_target("[[Old Name|caption]]", "New Name"),
|
||||
Some("[[New Name|caption]]".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
// :NuwikiDeleteFile — delete the current page and its on-disk file.
|
||||
#[test]
|
||||
fn nuwiki_delete_file_emits_delete_workspace_edit() {
|
||||
use tower_lsp::lsp_types::{DocumentChangeOperation, DocumentChanges, ResourceOp};
|
||||
let uri = Url::parse("file:///wiki/Doomed.wiki").unwrap();
|
||||
let mut b = nuwiki_lsp::edits::WorkspaceEditBuilder::new();
|
||||
b.file_op(nuwiki_lsp::edits::op_delete(uri.clone()));
|
||||
let we = b.build();
|
||||
|
||||
let DocumentChanges::Operations(ops) = we.document_changes.unwrap() else {
|
||||
panic!("expected resource operations");
|
||||
};
|
||||
assert!(matches!(
|
||||
&ops[0],
|
||||
DocumentChangeOperation::Op(ResourceOp::Delete(d)) if d.uri == uri
|
||||
));
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Group 2: Diary
|
||||
// =====================================================================
|
||||
|
||||
// :NuwikiMakeDiaryNote — open today's entry.
|
||||
#[test]
|
||||
fn nuwiki_make_diary_note_targets_todays_file() {
|
||||
let cfg = wiki_cfg("/wiki");
|
||||
let today = DiaryDate::from_ymd(2026, 5, 12).unwrap();
|
||||
let uri = diary::uri_for_date(&cfg, &today).unwrap();
|
||||
assert!(uri.as_str().ends_with("/diary/2026-05-12.wiki"));
|
||||
}
|
||||
|
||||
// :NuwikiMakeYesterdayDiaryNote / :NuwikiMakeTomorrowDiaryNote — step the
|
||||
// diary back / forward by one period at the daily cadence.
|
||||
#[test]
|
||||
fn nuwiki_yesterday_and_tomorrow_target_adjacent_files() {
|
||||
let cfg = wiki_cfg("/wiki");
|
||||
let yesterday = DiaryDate::from_ymd(2026, 5, 11).unwrap();
|
||||
let tomorrow = DiaryDate::from_ymd(2026, 5, 13).unwrap();
|
||||
assert!(diary::uri_for_date(&cfg, &yesterday)
|
||||
.unwrap()
|
||||
.as_str()
|
||||
.ends_with("/diary/2026-05-11.wiki"));
|
||||
assert!(diary::uri_for_date(&cfg, &tomorrow)
|
||||
.unwrap()
|
||||
.as_str()
|
||||
.ends_with("/diary/2026-05-13.wiki"));
|
||||
}
|
||||
|
||||
// :NuwikiDiaryIndex — open the diary index page.
|
||||
#[test]
|
||||
fn nuwiki_diary_index_resolves_index_uri() {
|
||||
let cfg = wiki_cfg("/wiki");
|
||||
let uri = diary::index_uri(&cfg).unwrap();
|
||||
assert!(uri.as_str().ends_with("/diary/diary.wiki"));
|
||||
}
|
||||
|
||||
// :NuwikiDiaryGenerateLinks — rebuild the diary index from disk entries.
|
||||
#[test]
|
||||
fn nuwiki_diary_generate_links_builds_grouped_body() {
|
||||
let idx = diary_index("/wiki", &["2026-05-10", "2026-05-12", "2026-04-30"]);
|
||||
let entries = diary::list_entries(&idx);
|
||||
let body = diary::build_index_body(&entries, "diary", "Diary");
|
||||
assert!(body.starts_with("= Diary ="));
|
||||
assert!(body.contains("2026-05-12"));
|
||||
assert!(body.contains("2026-05-10"));
|
||||
assert!(body.contains("2026-04-30"));
|
||||
// Newest entry appears before the older one within the same month.
|
||||
let p12 = body.find("2026-05-12").unwrap();
|
||||
let p10 = body.find("2026-05-10").unwrap();
|
||||
assert!(p12 < p10, "expected newest-first ordering");
|
||||
}
|
||||
|
||||
// :NuwikiDiaryNextDay / :NuwikiDiaryPrevDay — walk indexed entries.
|
||||
#[test]
|
||||
fn nuwiki_diary_next_and_prev_day_walk_entries() {
|
||||
let idx = diary_index("/wiki", &["2026-05-10", "2026-05-12", "2026-05-15"]);
|
||||
let from = DiaryDate::from_ymd(2026, 5, 12).unwrap();
|
||||
assert_eq!(
|
||||
diary::next_entry(&idx, &from).unwrap().date.format(),
|
||||
"2026-05-15"
|
||||
);
|
||||
assert_eq!(
|
||||
diary::prev_entry(&idx, &from).unwrap().date.format(),
|
||||
"2026-05-10"
|
||||
);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Group 3: Page generation
|
||||
// =====================================================================
|
||||
|
||||
// :NuwikiTOC — generate / refresh the table of contents.
|
||||
#[test]
|
||||
fn nuwiki_toc_generates_nested_contents() {
|
||||
let src = "= Top =\n== Sub ==\ntext\n";
|
||||
let doc = parse(src);
|
||||
let uri = Url::from_file_path("/wiki/Page.wiki").unwrap();
|
||||
let edit = ops::toc_edit(src, &doc, &uri, true).expect("toc edit produced");
|
||||
assert!(edit.changes.is_some() || edit.document_changes.is_some());
|
||||
|
||||
let toc = ops::build_toc_text(
|
||||
&[
|
||||
(1, "Top".into(), "top".into()),
|
||||
(2, "Sub".into(), "sub".into()),
|
||||
],
|
||||
"Contents",
|
||||
);
|
||||
assert!(toc.contains("= Contents ="));
|
||||
assert!(toc.contains("- [[#top|Top]]"));
|
||||
assert!(toc.contains(" - [[#sub|Sub]]"));
|
||||
}
|
||||
|
||||
// :NuwikiGenerateLinks — insert a flat list of every page in the wiki.
|
||||
#[test]
|
||||
fn nuwiki_generate_links_lists_all_pages_excluding_current() {
|
||||
let pages = vec!["About".to_string(), "Home".to_string(), "Notes".to_string()];
|
||||
let text = ops::build_links_text(&pages, "Links", Some("Home"));
|
||||
assert!(text.contains("= Links ="));
|
||||
assert!(text.contains("- [[About]]"));
|
||||
assert!(text.contains("- [[Notes]]"));
|
||||
assert!(!text.contains("[[Home]]"), "current page excluded");
|
||||
|
||||
let src = "= Existing =\n";
|
||||
let doc = parse(src);
|
||||
let uri = Url::from_file_path("/wiki/Home.wiki").unwrap();
|
||||
assert!(ops::links_edit(src, &doc, &uri, "Home", &pages, true).is_some());
|
||||
}
|
||||
|
||||
// :NuwikiCheckLinks — surface broken links across the workspace.
|
||||
#[test]
|
||||
fn nuwiki_check_links_distinguishes_broken_from_resolvable() {
|
||||
let idx = build_index(
|
||||
"/wiki",
|
||||
&[("Home", "[[About]] [[Ghost]]\n"), ("About", "= A =\n")],
|
||||
);
|
||||
let good = LinkTarget {
|
||||
kind: LinkKind::Wiki,
|
||||
path: Some("About".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let broken = LinkTarget {
|
||||
kind: LinkKind::Wiki,
|
||||
path: Some("Ghost".into()),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(idx.resolve(&good, "Home").is_some());
|
||||
assert!(
|
||||
idx.resolve(&broken, "Home").is_none(),
|
||||
"broken link unresolved"
|
||||
);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Group 4: Tags
|
||||
// =====================================================================
|
||||
|
||||
// :NuwikiSearchTags {tag} — every `:tag:` occurrence.
|
||||
#[test]
|
||||
fn nuwiki_search_tags_finds_occurrences() {
|
||||
let idx = build_index(
|
||||
"/wiki",
|
||||
&[("Home", ":alpha:beta:\n"), ("Work", ":alpha:\n")],
|
||||
);
|
||||
let hits = ops::tag_hits(&idx, "alpha");
|
||||
assert_eq!(hits.len(), 2);
|
||||
assert!(hits.iter().all(|h| h.name == "alpha"));
|
||||
assert!(ops::tag_hits(&idx, "nonexistent").is_empty());
|
||||
}
|
||||
|
||||
// :NuwikiGenerateTagLinks [tag] — section linking every page with a tag.
|
||||
#[test]
|
||||
fn nuwiki_generate_tag_links_builds_section() {
|
||||
let idx = build_index(
|
||||
"/wiki",
|
||||
&[
|
||||
("Home", ":alpha:\n"),
|
||||
("Work", ":alpha:\n"),
|
||||
("Misc", ":beta:\n"),
|
||||
],
|
||||
);
|
||||
let by_tag = ops::tag_pages_snapshot(&idx);
|
||||
|
||||
let single = ops::build_tag_links_text(&by_tag, Some("alpha")).unwrap();
|
||||
assert!(single.starts_with("= Tag: alpha ="));
|
||||
assert!(single.contains("- [[Home]]"));
|
||||
assert!(single.contains("- [[Work]]"));
|
||||
|
||||
// No-arg variant emits a section per tag.
|
||||
let all = ops::build_tag_links_text(&by_tag, None).unwrap();
|
||||
assert!(all.starts_with("= Tags ="));
|
||||
assert!(all.contains("== alpha =="));
|
||||
assert!(all.contains("== beta =="));
|
||||
|
||||
let src = "= Home =\n";
|
||||
let doc = parse(src);
|
||||
let uri = Url::from_file_path("/wiki/Home.wiki").unwrap();
|
||||
assert!(ops::tag_links_edit(src, &doc, &uri, Some("alpha"), &by_tag, true).is_some());
|
||||
}
|
||||
|
||||
// :NuwikiRebuildTags — force a full workspace re-index. A rebuild must
|
||||
// reflect the current on-disk tags, dropping stale ones.
|
||||
#[test]
|
||||
fn nuwiki_rebuild_tags_reflects_current_state() {
|
||||
let mut idx = WorkspaceIndex::new(Some(PathBuf::from("/wiki")));
|
||||
let uri = Url::from_file_path("/wiki/Home.wiki").unwrap();
|
||||
idx.upsert(uri.clone(), &parse(":old:\n"));
|
||||
assert_eq!(idx.tags_for("old").len(), 1);
|
||||
|
||||
// Re-index after the page changed on disk.
|
||||
idx.upsert(uri, &parse(":new:\n"));
|
||||
assert!(idx.tags_for("old").is_empty(), "stale tag dropped");
|
||||
assert_eq!(idx.tags_for("new").len(), 1);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Group 5: HTML export
|
||||
// =====================================================================
|
||||
|
||||
// :Nuwiki2HTML — render the current page to HTML.
|
||||
#[test]
|
||||
fn nuwiki_2html_renders_page() {
|
||||
let cfg = wiki_cfg("/wiki");
|
||||
let doc = parse("%title My Page\n= One =\nbody text\n");
|
||||
let html = export::render_page_html(
|
||||
&doc,
|
||||
Some("<title>{{title}}</title><main>{{content}}</main>".into()),
|
||||
"Home",
|
||||
DiaryDate::from_ymd(2026, 5, 12).unwrap(),
|
||||
&cfg.html,
|
||||
Some(".wiki"),
|
||||
HashMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(html.contains("<title>My Page</title>"));
|
||||
assert!(html.contains("body text"));
|
||||
}
|
||||
|
||||
// :Nuwiki2HTMLBrowse — render then open in the browser. The render output
|
||||
// is identical to :Nuwiki2HTML; the browse step is an editor-side open.
|
||||
#[test]
|
||||
fn nuwiki_2html_browse_shares_render_and_is_advertised() {
|
||||
assert!(COMMANDS.contains(&"nuwiki.export.browse"));
|
||||
let cfg = wiki_cfg("/wiki");
|
||||
let doc = parse("= Page =\nhi\n");
|
||||
let html = export::render_page_html(
|
||||
&doc,
|
||||
Some("{{content}}".into()),
|
||||
"Page",
|
||||
DiaryDate::today_utc(),
|
||||
&cfg.html,
|
||||
Some(".wiki"),
|
||||
HashMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(html.contains("hi"));
|
||||
}
|
||||
|
||||
// :NuwikiAll2HTML[!] — export every page; honour the exclude list and
|
||||
// per-page output paths.
|
||||
#[test]
|
||||
fn nuwiki_all2html_maps_output_paths_and_excludes() {
|
||||
let cfg = wiki_cfg("/wiki");
|
||||
assert!(export::output_path_for(&cfg.html, "Home").ends_with("Home.html"));
|
||||
assert!(
|
||||
export::output_path_for(&cfg.html, "diary/2026-05-12").ends_with("diary/2026-05-12.html")
|
||||
);
|
||||
|
||||
let patterns = vec!["_drafts/**".to_string()];
|
||||
assert!(export::is_excluded(&patterns, "_drafts/secret"));
|
||||
assert!(!export::is_excluded(&patterns, "Home"));
|
||||
|
||||
// Both incremental and forced (`!`) variants are advertised.
|
||||
assert!(COMMANDS.contains(&"nuwiki.export.allToHtml"));
|
||||
assert!(COMMANDS.contains(&"nuwiki.export.allToHtmlForce"));
|
||||
}
|
||||
|
||||
// :NuwikiRss — write `rss.xml` summarising recent diary entries.
|
||||
#[test]
|
||||
fn nuwiki_rss_writes_feed_file() {
|
||||
let root = std::env::temp_dir().join(format!("nuwiki-cov-rss-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let cfg = WikiConfig::from_root(root.clone());
|
||||
|
||||
let entries = vec![
|
||||
diary::DiaryEntry {
|
||||
date: DiaryDate::from_ymd(2026, 5, 10).unwrap(),
|
||||
uri: Url::from_file_path(root.join("diary/2026-05-10.wiki")).unwrap(),
|
||||
},
|
||||
diary::DiaryEntry {
|
||||
date: DiaryDate::from_ymd(2026, 5, 12).unwrap(),
|
||||
uri: Url::from_file_path(root.join("diary/2026-05-12.wiki")).unwrap(),
|
||||
},
|
||||
];
|
||||
|
||||
let path = export_ops::write_rss(&cfg, &entries).unwrap();
|
||||
assert!(path.ends_with("rss.xml"));
|
||||
let xml = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(xml.contains("<rss version=\"2.0\">"));
|
||||
// Newest entry listed first.
|
||||
let p12 = xml.find("2026-05-12").unwrap();
|
||||
let p10 = xml.find("2026-05-10").unwrap();
|
||||
assert!(p12 < p10);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Group 6: Other
|
||||
// =====================================================================
|
||||
|
||||
// :NuwikiInstall — install/re-install the `nuwiki-ls` binary. This is an
|
||||
// editor-side action (binary download / cargo build); it is deliberately
|
||||
// NOT an LSP executeCommand, so the server must not advertise it.
|
||||
#[test]
|
||||
fn nuwiki_install_is_editor_only_not_an_lsp_command() {
|
||||
assert!(!COMMANDS.iter().any(|c| c.contains("install")));
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Command-surface cross-check: every documented command that maps to an
|
||||
// executeCommand handler is actually advertised by the server.
|
||||
// =====================================================================
|
||||
|
||||
#[test]
|
||||
fn every_documented_lsp_command_is_advertised() {
|
||||
let expected = [
|
||||
"nuwiki.wiki.openIndex", // :NuwikiIndex
|
||||
"nuwiki.wiki.tabOpenIndex", // :NuwikiTabIndex
|
||||
"nuwiki.wiki.listAll", // :NuwikiUISelect
|
||||
"nuwiki.wiki.gotoPage", // :NuwikiGoto
|
||||
"nuwiki.file.delete", // :NuwikiDeleteFile
|
||||
"nuwiki.diary.openToday", // :NuwikiMakeDiaryNote
|
||||
"nuwiki.diary.openYesterday", // :NuwikiMakeYesterdayDiaryNote
|
||||
"nuwiki.diary.openTomorrow", // :NuwikiMakeTomorrowDiaryNote
|
||||
"nuwiki.diary.openIndex", // :NuwikiDiaryIndex
|
||||
"nuwiki.diary.generateIndex", // :NuwikiDiaryGenerateLinks
|
||||
"nuwiki.diary.next", // :NuwikiDiaryNextDay
|
||||
"nuwiki.diary.prev", // :NuwikiDiaryPrevDay
|
||||
"nuwiki.toc.generate", // :NuwikiTOC
|
||||
"nuwiki.links.generate", // :NuwikiGenerateLinks
|
||||
"nuwiki.workspace.checkLinks", // :NuwikiCheckLinks
|
||||
"nuwiki.tags.search", // :NuwikiSearchTags
|
||||
"nuwiki.tags.generateLinks", // :NuwikiGenerateTagLinks
|
||||
"nuwiki.tags.rebuild", // :NuwikiRebuildTags
|
||||
"nuwiki.export.currentToHtml", // :Nuwiki2HTML
|
||||
"nuwiki.export.browse", // :Nuwiki2HTMLBrowse
|
||||
"nuwiki.export.allToHtml", // :NuwikiAll2HTML
|
||||
"nuwiki.export.rss", // :NuwikiRss
|
||||
];
|
||||
for cmd in expected {
|
||||
assert!(COMMANDS.contains(&cmd), "server must advertise {cmd}");
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Phase 13: pure-function tests for the rename helper + the file-delete
|
||||
//! Pure-function tests for the rename helper + the file-delete
|
||||
//! command. The async `Backend::rename` end-to-end (with `read_or_open`
|
||||
//! hitting disk) is exercised by Phase 14+ integration but covered here
|
||||
//! hitting disk) is exercised by integration tests but covered here
|
||||
//! at the building-block level — same logic, no async client.
|
||||
|
||||
use std::path::PathBuf;
|
||||
@@ -95,7 +95,7 @@ async fn file_delete_returns_workspace_edit_with_delete_op() {
|
||||
//
|
||||
// Easier: shape the test as an integration test over the public
|
||||
// module surface — call `commands::execute` once we expose a thin
|
||||
// builder. For Phase 13 we test the JSON-shaped contract using the
|
||||
// builder. We test the JSON-shaped contract using the
|
||||
// edits module directly, since that's what `file_delete` returns.
|
||||
let uri = Url::parse("file:///tmp/note.wiki").unwrap();
|
||||
let mut b = nuwiki_lsp::edits::WorkspaceEditBuilder::new();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! §13.1 Cluster A — list rewriters: `removeDone`, `renumber`,
|
||||
//! Cluster A — list rewriters: `removeDone`, `renumber`,
|
||||
//! `changeSymbol`, `changeLevel`.
|
||||
|
||||
use nuwiki_core::ast::ListSymbol;
|
||||
@@ -86,10 +86,10 @@ fn remove_done_returns_none_when_no_lists() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_done_scoped_by_position_to_one_item() {
|
||||
// With `position` set, only items whose line range contains the
|
||||
// cursor line are considered. So a done item on line 1 is removed
|
||||
// by cursor on line 1, but a done item on line 3 is left alone.
|
||||
fn remove_done_scoped_by_position_to_current_list() {
|
||||
// With `position` set, the scope is the contiguous list block the
|
||||
// cursor sits in — every done item in that list is removed, not just
|
||||
// the one under the cursor.
|
||||
let src = "- [X] done one\n- [ ] todo\n- [X] done two\n";
|
||||
let ast = parse(src);
|
||||
let pos = Some(LspPosition {
|
||||
@@ -98,7 +98,37 @@ fn remove_done_scoped_by_position_to_one_item() {
|
||||
});
|
||||
let edit = ops::remove_done_edit(src, &ast, &uri(), pos, true).expect("edit");
|
||||
let edits = &edit.changes.unwrap()[&uri()];
|
||||
assert_eq!(edits.len(), 1, "only the line-0 item should match");
|
||||
assert_eq!(edits.len(), 2, "both done items in the current list match");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_done_position_leaves_other_lists_untouched() {
|
||||
// Two separate list blocks split by a paragraph. Cursor in the first
|
||||
// list removes only that list's done items; the second list's done
|
||||
// item survives.
|
||||
let src = "- [X] done a\n- [ ] todo a\n\nparagraph\n\n- [ ] todo b\n- [X] done b\n";
|
||||
let ast = parse(src);
|
||||
let pos = Some(LspPosition {
|
||||
line: 0,
|
||||
character: 0,
|
||||
});
|
||||
let edit = ops::remove_done_edit(src, &ast, &uri(), pos, true).expect("edit");
|
||||
let edits = &edit.changes.unwrap()[&uri()];
|
||||
assert_eq!(edits.len(), 1, "only the first list's done item is removed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_done_position_cascades_into_sublists() {
|
||||
// The current-list scope includes nested sublists of the block.
|
||||
let src = "- [ ] parent\n - [X] done child\n - [ ] open child\n";
|
||||
let ast = parse(src);
|
||||
let pos = Some(LspPosition {
|
||||
line: 0,
|
||||
character: 0,
|
||||
});
|
||||
let edit = ops::remove_done_edit(src, &ast, &uri(), pos, true).expect("edit");
|
||||
let edits = &edit.changes.unwrap()[&uri()];
|
||||
assert_eq!(edits.len(), 1, "the nested done child is removed");
|
||||
}
|
||||
|
||||
// ===== renumber =====
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! §13.1 Cluster B — table rewriters: `insert`, `align`, `moveColumn`.
|
||||
//! Cluster B — table rewriters: `insert`, `align`, `moveColumn`.
|
||||
|
||||
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
|
||||
use nuwiki_core::syntax::SyntaxPlugin;
|
||||
|
||||
@@ -337,7 +337,7 @@ fn diary_entry_serializes_to_date_string_and_uri() {
|
||||
// ===== COMMANDS list completeness =====
|
||||
|
||||
#[test]
|
||||
fn commands_list_includes_phase16_entries() {
|
||||
fn commands_list_includes_diary_entries() {
|
||||
let names: Vec<&str> = nuwiki_lsp::commands::COMMANDS.to_vec();
|
||||
for name in [
|
||||
"nuwiki.diary.openToday",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Phase 19: LSP folding-range provider.
|
||||
//! LSP folding-range provider.
|
||||
//!
|
||||
//! Drives `folding::folding_ranges` directly. The handler wiring is
|
||||
//! shallow — it just calls into this function — so we verify the
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Tests for the pure helpers that the LSP backend uses to translate
|
||||
//! between `nuwiki-core` ASTs and the LSP wire format. The async
|
||||
//! request-handling loop is exercised at integration time in Phase 8+
|
||||
//! (navigation), so this file stays focused on conversion correctness.
|
||||
//! request-handling loop is exercised at integration time by the
|
||||
//! navigation tests, so this file stays focused on conversion correctness.
|
||||
|
||||
use nuwiki_core::ast::{
|
||||
inline::InlineNode, BlockNode, BlockquoteNode, DocumentNode, ErrorNode, ParagraphNode,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Phase 17: HTML export commands.
|
||||
//! HTML export commands.
|
||||
//!
|
||||
//! Drives the pure `export::*` helpers directly. The side-effecting
|
||||
//! `export_ops::write_page` / `write_rss` paths get exercised via a
|
||||
@@ -474,7 +474,7 @@ fn write_rss_emits_valid_xml_with_entries() {
|
||||
// ===== COMMANDS list completeness =====
|
||||
|
||||
#[test]
|
||||
fn commands_list_includes_phase17_entries() {
|
||||
fn commands_list_includes_export_entries() {
|
||||
let names: Vec<&str> = nuwiki_lsp::commands::COMMANDS.to_vec();
|
||||
for name in [
|
||||
"nuwiki.export.currentToHtml",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Phase 15: link-health diagnostics + TOC/links/orphans queries.
|
||||
//! Link-health diagnostics + TOC/links/orphans queries.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -630,7 +630,7 @@ fn anchor_matches_unicode_heading() {
|
||||
// ===== COMMANDS completeness =====
|
||||
|
||||
#[test]
|
||||
fn commands_list_includes_phase15_entries() {
|
||||
fn commands_list_includes_link_health_entries() {
|
||||
let names: Vec<&str> = nuwiki_lsp::commands::COMMANDS.to_vec();
|
||||
for name in [
|
||||
"nuwiki.toc.generate",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! Phase 18: multi-wiki — interwiki resolution + picker commands.
|
||||
//! Multi-wiki — interwiki resolution + picker commands.
|
||||
//!
|
||||
//! The cross-wiki resolver methods on `Backend` are exercised indirectly
|
||||
//! through `tower_lsp::LspService`'s test harness via a synthetic
|
||||
//! `Backend` (the same pattern Phase 13 used). To keep this test suite
|
||||
//! `Backend`. To keep this test suite
|
||||
//! lightweight we drive the *pure* resolution primitives — the parser
|
||||
//! producing `LinkKind::Interwiki` targets, then the `WorkspaceIndex`
|
||||
//! lookup-by-name — and verify the multi-wiki config shape end-to-end.
|
||||
@@ -80,7 +80,7 @@ fn multi_wiki_config_loads_two_entries() {
|
||||
|
||||
#[test]
|
||||
fn v1_0_single_root_still_works_alongside_multi_wiki_shape() {
|
||||
// Single-wiki shorthand is the v1.0 form; ensure it still desugars.
|
||||
// Single-wiki shorthand is the legacy form; ensure it still desugars.
|
||||
let cfg = config_from_json(serde_json::json!({
|
||||
"wiki_root": "/tmp/legacy",
|
||||
}));
|
||||
@@ -135,7 +135,7 @@ fn nested_root_picks_longest_prefix_match() {
|
||||
|
||||
// ===== Cross-wiki resolution (uses WorkspaceIndex per wiki) =====
|
||||
|
||||
/// Verify that the cross-wiki resolution semantics match the SPEC:
|
||||
/// Verify the cross-wiki resolution semantics:
|
||||
/// `[[wiki1:Page]]` looks up `Page` in the *second* wiki's index, not
|
||||
/// the source wiki's. The pure-data check here mirrors what
|
||||
/// `Backend::resolve_target_uri` does.
|
||||
@@ -185,7 +185,7 @@ fn interwiki_resolution_falls_back_when_wiki_missing() {
|
||||
// ===== Wiki commands: COMMANDS list completeness =====
|
||||
|
||||
#[test]
|
||||
fn commands_list_includes_phase18_entries() {
|
||||
fn commands_list_includes_wiki_entries() {
|
||||
let names: Vec<&str> = nuwiki_lsp::commands::COMMANDS.to_vec();
|
||||
for name in [
|
||||
"nuwiki.wiki.listAll",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Tests for the Phase 8 navigation pieces: WorkspaceIndex queries,
|
||||
//! Tests for the navigation pieces: WorkspaceIndex queries,
|
||||
//! page-name derivation, anchor slugify, position lookup, and link
|
||||
//! resolution. The async LSP handlers are exercised through these
|
||||
//! helpers; end-to-end JSON-RPC drive lives in a follow-up phase.
|
||||
//! helpers; end-to-end JSON-RPC drive lives elsewhere.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user