phase 2: syntax plugin interface and registry
Add the Lexer/Parser/SyntaxPlugin traits and the SyntaxRegistry per SPEC.md §6.3. - `Lexer<Token>` and `Parser<Token>` are typed building blocks: each syntax owns its own token alphabet (vimwiki today, markdown later). - `TokenStream<T>` is a `Vec<T>` newtype so the eager-vs-lazy choice (§6.6) can flip later without breaking callers. - `SyntaxPlugin` is type-erased: it exposes id / display_name / file_extensions / parse(text) so the registry can hold heterogeneous plugins behind a single trait object. Implementations chain their Lexer + Parser inside parse(). - `SyntaxPlugin: Send + Sync` so the LSP server can stash plugins in an Arc and share them across handler tasks. - `SyntaxRegistry` stores `Arc<dyn SyntaxPlugin>`, supports lookup by id and by file extension, iteration in registration order. Tests cover TokenStream round-trip, full lex→parse chain through a mock plugin, registry lookup hits and misses, and a compile-time assert_send_sync::<SyntaxRegistry>(). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
//! Integration tests for the syntax plugin interface.
|
||||
|
||||
use nuwiki_core::ast::{
|
||||
inline::InlineNode, BlockNode, DocumentNode, ParagraphNode, Span, TextNode,
|
||||
};
|
||||
use nuwiki_core::syntax::{Lexer, Parser, SyntaxPlugin, SyntaxRegistry, TokenStream};
|
||||
|
||||
// --- A minimal mock plugin: tokenises the input on whitespace, then wraps
|
||||
// the joined text in a single Paragraph. Just enough surface to exercise
|
||||
// every trait + the registry.
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct MockToken(String);
|
||||
|
||||
struct MockLexer;
|
||||
impl Lexer for MockLexer {
|
||||
type Token = MockToken;
|
||||
|
||||
fn lex(&self, text: &str) -> TokenStream<MockToken> {
|
||||
TokenStream::from_vec(
|
||||
text.split_whitespace()
|
||||
.map(|w| MockToken(w.to_owned()))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct MockParser;
|
||||
impl Parser for MockParser {
|
||||
type Token = MockToken;
|
||||
|
||||
fn parse(&self, tokens: TokenStream<MockToken>) -> DocumentNode {
|
||||
let joined = tokens
|
||||
.iter()
|
||||
.map(|t| t.0.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let paragraph = BlockNode::Paragraph(ParagraphNode {
|
||||
span: Span::default(),
|
||||
children: vec![InlineNode::Text(TextNode {
|
||||
span: Span::default(),
|
||||
content: joined,
|
||||
})],
|
||||
});
|
||||
DocumentNode {
|
||||
span: Span::default(),
|
||||
children: vec![paragraph],
|
||||
metadata: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct MockPlugin;
|
||||
impl SyntaxPlugin for MockPlugin {
|
||||
fn id(&self) -> &str {
|
||||
"mock"
|
||||
}
|
||||
fn display_name(&self) -> &str {
|
||||
"Mock Syntax"
|
||||
}
|
||||
fn file_extensions(&self) -> &[&str] {
|
||||
&[".mock", ".mk"]
|
||||
}
|
||||
fn parse(&self, text: &str) -> DocumentNode {
|
||||
MockParser.parse(MockLexer.lex(text))
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_stream_roundtrip() {
|
||||
let mut s = TokenStream::<u32>::new();
|
||||
assert!(s.is_empty());
|
||||
s.push(1);
|
||||
s.push(2);
|
||||
s.push(3);
|
||||
assert_eq!(s.len(), 3);
|
||||
assert_eq!(s.as_slice(), &[1, 2, 3]);
|
||||
assert_eq!(s.iter().sum::<u32>(), 6);
|
||||
assert_eq!((&s).into_iter().sum::<u32>(), 6);
|
||||
assert_eq!(s.into_vec(), vec![1, 2, 3]);
|
||||
|
||||
let from_vec: TokenStream<&str> = vec!["a", "b"].into();
|
||||
assert_eq!(from_vec.len(), 2);
|
||||
let collected: Vec<&str> = from_vec.into_iter().collect();
|
||||
assert_eq!(collected, vec!["a", "b"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lexer_and_parser_chain_through_plugin() {
|
||||
let plugin = MockPlugin;
|
||||
let doc = plugin.parse(" hello world ");
|
||||
|
||||
let BlockNode::Paragraph(p) = &doc.children[0] else {
|
||||
panic!("expected a paragraph, got {:?}", doc.children[0]);
|
||||
};
|
||||
let InlineNode::Text(t) = &p.children[0] else {
|
||||
panic!("expected a text node");
|
||||
};
|
||||
assert_eq!(t.content, "hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_lookup_by_id_and_extension() {
|
||||
let mut registry = SyntaxRegistry::new();
|
||||
assert!(registry.is_empty());
|
||||
|
||||
registry.register(MockPlugin);
|
||||
assert_eq!(registry.len(), 1);
|
||||
|
||||
let by_id = registry
|
||||
.get("mock")
|
||||
.expect("plugin should be findable by id");
|
||||
assert_eq!(by_id.display_name(), "Mock Syntax");
|
||||
|
||||
let by_ext = registry
|
||||
.detect_from_extension(".mk")
|
||||
.expect("plugin should be findable by secondary extension");
|
||||
assert_eq!(by_ext.id(), "mock");
|
||||
|
||||
assert!(registry.get("nonexistent").is_none());
|
||||
assert!(registry.detect_from_extension(".unknown").is_none());
|
||||
|
||||
// Iteration order matches registration order.
|
||||
let ids: Vec<&str> = registry.iter().map(|p| p.id()).collect();
|
||||
assert_eq!(ids, vec!["mock"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_can_parse_through_trait_object() {
|
||||
let mut registry = SyntaxRegistry::new();
|
||||
registry.register(MockPlugin);
|
||||
|
||||
let plugin = registry.detect_from_extension(".mock").unwrap();
|
||||
let doc = plugin.parse("a b c");
|
||||
assert_eq!(doc.children.len(), 1);
|
||||
}
|
||||
|
||||
/// Compile-time check: SyntaxRegistry must be Send + Sync so the LSP server
|
||||
/// can share it across handler tasks (SPEC.md §6.3).
|
||||
#[test]
|
||||
fn registry_is_send_and_sync() {
|
||||
fn assert_send_sync<T: Send + Sync>() {}
|
||||
assert_send_sync::<SyntaxRegistry>();
|
||||
}
|
||||
Reference in New Issue
Block a user