//! 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); }