phase 6: LSP foundation — didOpen/didChange, diagnostics, outline
`tower-lsp` server (`nuwiki-lsp::run` + `run_stdio`) wired up by the `nuwiki-ls` binary. Backend holds an `Arc<DashMap<Url, DocumentState>>` per SPEC §6.10 plus an `Arc<SyntaxRegistry>` pre-loaded with `VimwikiSyntax`. Re-parse is full per change; incremental parsing deferred post-v1 per spec. Capabilities declared: - `text_document_sync = FULL` - `document_symbol_provider = true` - `position_encoding = UTF-8` when the client opts into LSP 3.17+ encodings (SPEC §6.11). Otherwise we keep the legacy UTF-16 default and translate spans through a per-line UTF-16 code-unit walk that handles BMP and surrogate pairs alike. Handlers: `initialize` (negotiate encoding), `initialized` (log it), `shutdown`, `did_open` / `did_change` (full re-parse + `publishDiagnostics`), `did_close` (drop state, clear diagnostics), `document_symbol` (nested outline). Diagnostics: every `BlockNode::Error` (from §6.7's resilient parser) emits one `DiagnosticSeverity::ERROR` with `source = "nuwiki"`. The walker descends into Blockquotes and ListItems so errors nested inside those don't slip through. Document outline: nested `DocumentSymbol` tree built recursively from heading levels (`= H1 =` → root, `== H2 ==` → child of preceding H1, etc.). Titles flatten inline markers via `inline_to_text` so `*world*` shows up as `world` in the outline. Pure helpers (`to_lsp_position`, `to_lsp_range`, `ast_diagnostics`, `headings_to_symbols`) are `pub` so the integration tests can verify conversion correctness without spinning up the async server. End-to- end LSP message exercises will land in Phase 8 alongside navigation. Tests (11): UTF-8 passthrough, UTF-16 conversion for é + 🦀, single and multi-error diagnostic emission, blockquote descent, flat outline, nested outline by levels, inline-marker stripping in titles, empty- heading fallback, registry-dispatch parity. Dep pin: `idna_adapter` held at 1.2.0 — 1.2.2 needs Cargo's edition2024 feature (Rust 1.85), our MSRV is 1.83. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,255 @@
|
||||
//! 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.
|
||||
|
||||
use nuwiki_core::ast::{
|
||||
inline::InlineNode, BlockNode, BlockquoteNode, DocumentNode, ErrorNode, ParagraphNode,
|
||||
Position, Span, TextNode,
|
||||
};
|
||||
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
|
||||
use nuwiki_core::syntax::SyntaxPlugin;
|
||||
use nuwiki_lsp::{ast_diagnostics, headings_to_symbols, to_lsp_position, to_lsp_range};
|
||||
use tower_lsp::lsp_types::{DiagnosticSeverity, DocumentSymbolResponse, SymbolKind};
|
||||
|
||||
// ===== Position / Range conversion =====
|
||||
|
||||
#[test]
|
||||
fn position_passthrough_in_utf8_mode() {
|
||||
let pos = Position {
|
||||
line: 3,
|
||||
column: 7,
|
||||
offset: 0,
|
||||
};
|
||||
let lsp = to_lsp_position(&pos, "ignored", true);
|
||||
assert_eq!(lsp.line, 3);
|
||||
assert_eq!(lsp.character, 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn position_translates_to_utf16_for_multibyte_chars() {
|
||||
// "héllo" in UTF-8: h(1) é(2 bytes) l(1) l(1) o(1) — total 6 bytes.
|
||||
// In UTF-16 each char is one code unit (BMP), so byte col 3 ("after é")
|
||||
// maps to UTF-16 char col 2.
|
||||
let line = "héllo\nworld\n";
|
||||
let pos = Position {
|
||||
line: 0,
|
||||
column: 3,
|
||||
offset: 3,
|
||||
};
|
||||
let lsp = to_lsp_position(&pos, line, false);
|
||||
assert_eq!(lsp.line, 0);
|
||||
assert_eq!(lsp.character, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn position_handles_astral_plane_in_utf16() {
|
||||
// 🦀 is U+1F980, two UTF-16 code units (surrogate pair), four UTF-8 bytes.
|
||||
let text = "🦀x\n";
|
||||
// After the crab + x, byte col is 5, UTF-16 col is 3 (2 surrogates + 1 BMP).
|
||||
let pos = Position {
|
||||
line: 0,
|
||||
column: 5,
|
||||
offset: 5,
|
||||
};
|
||||
let lsp = to_lsp_position(&pos, text, false);
|
||||
assert_eq!(lsp.character, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn range_passthrough_in_utf8_mode() {
|
||||
let span = Span::new(
|
||||
Position {
|
||||
line: 0,
|
||||
column: 0,
|
||||
offset: 0,
|
||||
},
|
||||
Position {
|
||||
line: 0,
|
||||
column: 5,
|
||||
offset: 5,
|
||||
},
|
||||
);
|
||||
let r = to_lsp_range(&span, "hello\n", true);
|
||||
assert_eq!(r.start.line, 0);
|
||||
assert_eq!(r.start.character, 0);
|
||||
assert_eq!(r.end.character, 5);
|
||||
}
|
||||
|
||||
// ===== Diagnostics =====
|
||||
|
||||
#[test]
|
||||
fn ast_diagnostics_yields_one_per_error_node() {
|
||||
let span_a = Span::new(
|
||||
Position {
|
||||
line: 0,
|
||||
column: 0,
|
||||
offset: 0,
|
||||
},
|
||||
Position {
|
||||
line: 0,
|
||||
column: 4,
|
||||
offset: 4,
|
||||
},
|
||||
);
|
||||
let span_b = Span::new(
|
||||
Position {
|
||||
line: 2,
|
||||
column: 0,
|
||||
offset: 10,
|
||||
},
|
||||
Position {
|
||||
line: 2,
|
||||
column: 3,
|
||||
offset: 13,
|
||||
},
|
||||
);
|
||||
let doc = DocumentNode {
|
||||
span: Span::default(),
|
||||
metadata: Default::default(),
|
||||
children: vec![
|
||||
BlockNode::Error(ErrorNode {
|
||||
span: span_a,
|
||||
raw: "first".into(),
|
||||
message: "first parse failure".into(),
|
||||
}),
|
||||
BlockNode::Paragraph(ParagraphNode {
|
||||
span: Span::default(),
|
||||
children: vec![InlineNode::Text(TextNode {
|
||||
span: Span::default(),
|
||||
content: "ok".into(),
|
||||
})],
|
||||
}),
|
||||
BlockNode::Error(ErrorNode {
|
||||
span: span_b,
|
||||
raw: "second".into(),
|
||||
message: "second parse failure".into(),
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
let diags = ast_diagnostics(&doc, "ignored", true);
|
||||
assert_eq!(diags.len(), 2);
|
||||
assert_eq!(diags[0].message, "first parse failure");
|
||||
assert_eq!(diags[0].severity, Some(DiagnosticSeverity::ERROR));
|
||||
assert_eq!(diags[0].source.as_deref(), Some("nuwiki"));
|
||||
assert_eq!(diags[0].range.start.line, 0);
|
||||
assert_eq!(diags[1].range.start.line, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ast_diagnostics_descends_into_blockquotes() {
|
||||
let span = Span::new(
|
||||
Position {
|
||||
line: 1,
|
||||
column: 2,
|
||||
offset: 0,
|
||||
},
|
||||
Position {
|
||||
line: 1,
|
||||
column: 6,
|
||||
offset: 4,
|
||||
},
|
||||
);
|
||||
let inner_error = BlockNode::Error(ErrorNode {
|
||||
span,
|
||||
raw: "x".into(),
|
||||
message: "nested".into(),
|
||||
});
|
||||
let bq = BlockNode::Blockquote(BlockquoteNode {
|
||||
span: Span::default(),
|
||||
children: vec![inner_error],
|
||||
});
|
||||
let doc = DocumentNode {
|
||||
span: Span::default(),
|
||||
metadata: Default::default(),
|
||||
children: vec![bq],
|
||||
};
|
||||
|
||||
let diags = ast_diagnostics(&doc, "", true);
|
||||
assert_eq!(diags.len(), 1);
|
||||
assert_eq!(diags[0].message, "nested");
|
||||
}
|
||||
|
||||
// ===== Document symbols =====
|
||||
|
||||
fn parse(src: &str) -> DocumentNode {
|
||||
VimwikiSyntax::new().parse(src)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_outline_when_all_headings_same_level() {
|
||||
let doc = parse("= A =\n= B =\n= C =\n");
|
||||
let syms = headings_to_symbols(&doc, "", true);
|
||||
assert_eq!(syms.len(), 3);
|
||||
let names: Vec<&str> = syms.iter().map(|s| s.name.as_str()).collect();
|
||||
assert_eq!(names, vec!["A", "B", "C"]);
|
||||
for s in &syms {
|
||||
assert!(s.children.is_none() || s.children.as_ref().unwrap().is_empty());
|
||||
assert_eq!(s.kind, SymbolKind::STRING);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_outline_follows_heading_levels() {
|
||||
let src = "\
|
||||
= Top =
|
||||
== Sub A ==
|
||||
=== Deeper ===
|
||||
== Sub B ==
|
||||
= Other =
|
||||
";
|
||||
let doc = parse(src);
|
||||
let syms = headings_to_symbols(&doc, "", true);
|
||||
assert_eq!(syms.len(), 2);
|
||||
|
||||
// Top contains Sub A and Sub B; Sub A contains Deeper.
|
||||
let top = &syms[0];
|
||||
assert_eq!(top.name, "Top");
|
||||
let top_kids = top.children.as_ref().expect("Top should have children");
|
||||
assert_eq!(top_kids.len(), 2);
|
||||
assert_eq!(top_kids[0].name, "Sub A");
|
||||
assert_eq!(top_kids[1].name, "Sub B");
|
||||
|
||||
let sub_a_kids = top_kids[0]
|
||||
.children
|
||||
.as_ref()
|
||||
.expect("Sub A should have children");
|
||||
assert_eq!(sub_a_kids.len(), 1);
|
||||
assert_eq!(sub_a_kids[0].name, "Deeper");
|
||||
|
||||
assert_eq!(syms[1].name, "Other");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heading_title_strips_inline_markers() {
|
||||
let doc = parse("= Hello *world* and `code` =\n");
|
||||
let syms = headings_to_symbols(&doc, "", true);
|
||||
assert_eq!(syms.len(), 1);
|
||||
assert_eq!(syms[0].name, "Hello world and code");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_heading_falls_back_to_placeholder_name() {
|
||||
let doc = parse("= =\n");
|
||||
let syms = headings_to_symbols(&doc, "", true);
|
||||
if let Some(s) = syms.first() {
|
||||
assert!(!s.name.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
// ===== End-to-end through the registry =====
|
||||
|
||||
#[test]
|
||||
fn outline_symbols_match_registry_dispatch() {
|
||||
let src = "= A =\n== B ==\n";
|
||||
let plugin = VimwikiSyntax::new();
|
||||
let doc = plugin.parse(src);
|
||||
let syms = headings_to_symbols(&doc, src, true);
|
||||
assert_eq!(syms.len(), 1);
|
||||
assert_eq!(syms[0].name, "A");
|
||||
let kids = syms[0].children.as_ref().unwrap();
|
||||
assert_eq!(kids[0].name, "B");
|
||||
// Sanity: symbols can be wrapped in DocumentSymbolResponse::Nested.
|
||||
let _resp = DocumentSymbolResponse::Nested(syms);
|
||||
}
|
||||
Reference in New Issue
Block a user