use satteri_arena::{Arena, ArenaBuilder, Mdast, StringRef};
use satteri_ast::mdast::{MdastNodeType, codec::*};
use satteri_plugin_api::*;
fn reachable_ids(arena: &Arena<Mdast>) -> Vec<u32> {
let mut out = Vec::new();
let mut stack = vec![0u32];
while let Some(id) = stack.pop() {
out.push(id);
stack.extend_from_slice(arena.get_children(id));
}
out
}
fn build_test_arena() -> Arena<Mdast> {
let source = "# Hello\n\nWorld".to_string();
let mut b = ArenaBuilder::<Mdast>::new(source);
b.open_node(MdastNodeType::Root as u8);
b.open_node(MdastNodeType::Heading as u8);
b.set_position_current(0, 7, 1, 1, 1, 8);
b.set_data_current(&encode_heading_data(1));
b.open_node(MdastNodeType::Text as u8);
b.set_position_current(2, 7, 1, 3, 1, 8);
b.set_data_current(&encode_string_ref_data(StringRef::new(2, 5)));
b.close_node();
b.close_node();
b.open_node(MdastNodeType::Paragraph as u8);
b.set_position_current(9, 14, 2, 1, 2, 6);
b.open_node(MdastNodeType::Text as u8);
b.set_position_current(9, 14, 2, 1, 2, 6);
b.set_data_current(&encode_string_ref_data(StringRef::new(9, 5)));
b.close_node();
b.close_node(); b.close_node();
b.finish()
}
struct RemoveAllText;
impl Plugin for RemoveAllText {
fn meta(&self) -> PluginMeta {
PluginMeta::new("remove-all-text")
}
fn visit_text(&mut self, node: &Text, _ctx: &mut PluginContext) -> VisitResult {
let _ = node;
VisitResult::Remove
}
}
#[test]
fn remove_text_via_visit_result_removes_from_arena() {
let arena = build_test_arena();
let original_count = arena.len();
let mut runner = PluginRunner::new(vec![Box::new(RemoveAllText)]);
let mut data_map = DataMap::new();
let mut typed_data = TypedDataMap::new();
let result = runner.run(arena, &mut data_map, &mut typed_data);
assert!(result.has_mutations, "should have mutations after remove");
let reachable = reachable_ids(&result.arena).len();
assert!(
reachable < original_count,
"arena should have fewer nodes: got {reachable}, was {original_count}"
);
for id in reachable_ids(&result.arena) {
let node_type = result.arena.get_node(id).node_type;
assert_ne!(
node_type,
MdastNodeType::Text as u8,
"no Text nodes should remain after remove, found one at id={}",
id
);
}
}
struct ReplaceHeadingWithParagraph;
impl Plugin for ReplaceHeadingWithParagraph {
fn meta(&self) -> PluginMeta {
PluginMeta::new("replace-heading-with-para")
}
fn visit_heading(&mut self, _node: &Heading, _ctx: &mut PluginContext) -> VisitResult {
VisitResult::Replace(NodeBuilder::paragraph().build())
}
}
#[test]
fn replace_heading_via_visit_result_updates_arena() {
let arena = build_test_arena();
let mut runner = PluginRunner::new(vec![Box::new(ReplaceHeadingWithParagraph)]);
let mut data_map = DataMap::new();
let mut typed_data = TypedDataMap::new();
let result = runner.run(arena, &mut data_map, &mut typed_data);
assert!(result.has_mutations);
let has_heading = reachable_ids(&result.arena)
.iter()
.any(|&id| result.arena.get_node(id).node_type == MdastNodeType::Heading as u8);
assert!(!has_heading, "no headings should remain after replacement");
let root_children = result.arena.get_children(0);
assert!(!root_children.is_empty(), "root should still have children");
}
struct ReadOnlyPlugin;
impl Plugin for ReadOnlyPlugin {
fn meta(&self) -> PluginMeta {
PluginMeta::new("read-only")
}
fn visit_heading(&mut self, _node: &Heading, _ctx: &mut PluginContext) -> VisitResult {
VisitResult::NoChange
}
fn visit_paragraph(&mut self, _node: &Paragraph, _ctx: &mut PluginContext) -> VisitResult {
VisitResult::NoChange
}
fn visit_text(&mut self, _node: &Text, _ctx: &mut PluginContext) -> VisitResult {
VisitResult::NoChange
}
}
#[test]
fn read_only_plugin_does_not_mutate_arena() {
let arena = build_test_arena();
let original_count = arena.len();
let mut runner = PluginRunner::new(vec![Box::new(ReadOnlyPlugin)]);
let mut data_map = DataMap::new();
let mut typed_data = TypedDataMap::new();
let result = runner.run(arena, &mut data_map, &mut typed_data);
assert!(
!result.has_mutations,
"read-only plugin should not cause mutations"
);
assert_eq!(result.arena.len(), original_count, "node count unchanged");
assert!(result.commands.is_empty(), "no commands recorded");
}
fn slugify(text: &str) -> String {
text.chars()
.map(|c| {
if c.is_alphanumeric() {
c.to_lowercase().next().unwrap()
} else {
'-'
}
})
.collect::<String>()
.trim_matches('-')
.to_string()
}
struct AddHeadingIds;
impl Plugin for AddHeadingIds {
fn meta(&self) -> PluginMeta {
PluginMeta::new("add-heading-ids")
}
fn visit_heading(&mut self, node: &Heading, ctx: &mut PluginContext) -> VisitResult {
let text = ctx.extract_text(node.id());
let id = slugify(&text);
ctx.set_data(node.id(), "id", DataValue::String(id));
VisitResult::NoChange
}
}
#[test]
fn data_only_plugin_does_not_trigger_mutation() {
let arena = build_test_arena();
let original_count = arena.len();
let mut runner = PluginRunner::new(vec![Box::new(AddHeadingIds)]);
let mut data_map = DataMap::new();
let mut typed_data = TypedDataMap::new();
let result = runner.run(arena, &mut data_map, &mut typed_data);
assert!(
!result.has_mutations,
"data-only plugin should not set has_mutations"
);
assert_eq!(result.arena.len(), original_count, "arena is unchanged");
assert!(
result.commands.is_empty(),
"no commands from data-only plugin"
);
assert!(data_map.has(1, "id"), "id should be set by AddHeadingIds");
let id_val = data_map.get(1, "id").unwrap();
assert_eq!(id_val.as_str().unwrap(), "hello");
}
struct RemoveHeadingExplicit;
impl Plugin for RemoveHeadingExplicit {
fn meta(&self) -> PluginMeta {
PluginMeta::new("remove-heading-explicit")
}
fn visit_heading(&mut self, node: &Heading, ctx: &mut PluginContext) -> VisitResult {
ctx.remove_node(node.id());
VisitResult::NoChange
}
}
#[test]
fn explicit_remove_command_mutates_arena() {
let arena = build_test_arena();
let mut runner = PluginRunner::new(vec![Box::new(RemoveHeadingExplicit)]);
let mut data_map = DataMap::new();
let mut typed_data = TypedDataMap::new();
let result = runner.run(arena, &mut data_map, &mut typed_data);
assert!(result.has_mutations);
let reachable = reachable_ids(&result.arena);
let has_heading = reachable
.iter()
.any(|&id| result.arena.get_node(id).node_type == MdastNodeType::Heading as u8);
assert!(!has_heading, "heading should be removed from arena");
assert_eq!(reachable.len(), 3);
}
struct RecordVisits {
seen: std::sync::Arc<std::sync::Mutex<Vec<(u8, u32)>>>,
}
impl RecordVisits {
fn record(&self, node_type: MdastNodeType, id: u32) {
self.seen.lock().unwrap().push((node_type as u8, id));
}
}
impl Plugin for RecordVisits {
fn meta(&self) -> PluginMeta {
PluginMeta::new("record-visits")
}
fn visit_heading(&mut self, node: &Heading, _ctx: &mut PluginContext) -> VisitResult {
self.record(MdastNodeType::Heading, node.id());
VisitResult::NoChange
}
fn visit_paragraph(&mut self, node: &Paragraph, _ctx: &mut PluginContext) -> VisitResult {
self.record(MdastNodeType::Paragraph, node.id());
VisitResult::NoChange
}
fn visit_text(&mut self, node: &Text, _ctx: &mut PluginContext) -> VisitResult {
self.record(MdastNodeType::Text, node.id());
VisitResult::NoChange
}
}
#[test]
fn second_plugin_visits_only_reachable_nodes() {
let arena = build_test_arena();
let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let recorder = RecordVisits { seen: seen.clone() };
let mut runner = PluginRunner::new(vec![Box::new(RemoveHeadingExplicit), Box::new(recorder)]);
let mut data_map = DataMap::new();
let mut typed_data = TypedDataMap::new();
let result = runner.run(arena, &mut data_map, &mut typed_data);
let seen = seen.lock().unwrap();
assert!(
!seen.iter().any(|&(t, _)| t == MdastNodeType::Heading as u8),
"second plugin must not visit the removed heading: {seen:?}"
);
let text_visits = seen
.iter()
.filter(|&&(t, _)| t == MdastNodeType::Text as u8)
.count();
assert_eq!(
text_visits, 1,
"only the paragraph's text is reachable: {seen:?}"
);
assert_eq!(reachable_ids(&result.arena).len(), 3);
}