satteri-plugin-api 0.5.2

Rust plugin trait, typed visitors, and runner for Sätteri
Documentation
//! Integration tests verifying that PluginRunner actually applies structural commands.

use satteri_arena::{Arena, ArenaBuilder, Mdast, StringRef};
use satteri_ast::mdast::{MdastNodeType, codec::*};
use satteri_plugin_api::*;

/// In-place apply leaves detached garbage; assertions must only consider
/// nodes reachable from the root.
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(); // heading

    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(); // paragraph
    b.close_node(); // root

    b.finish()
}

/// A plugin that removes all Text nodes by returning VisitResult::Remove.
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 {
        // Using the visitor return value path
        let _ = node;
        VisitResult::Remove
    }
}

#[test]
fn remove_text_via_visit_result_removes_from_arena() {
    let arena = build_test_arena();
    let original_count = arena.len(); // 5

    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");

    // Original had 2 Text nodes. They should be gone.
    let reachable = reachable_ids(&result.arena).len();
    assert!(
        reachable < original_count,
        "arena should have fewer nodes: got {reachable}, was {original_count}"
    );

    // No Text nodes should remain reachable
    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
        );
    }
}

/// A plugin that replaces the heading with a paragraph via VisitResult::Replace.
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);

    // No Heading should remain reachable
    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");

    // Root should still have children
    let root_children = result.arena.get_children(0);
    assert!(!root_children.is_empty(), "root should still have children");
}

/// A read-only plugin that only observes nodes.
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);

    // Skip optimization: no mutations, arena untouched
    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");
}

/// AddHeadingIds writes to the DataMap but issues no structural commands.
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);

    // Data written to DataMap, but no structural arena commands
    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"
    );

    // The data should be in the data_map
    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);

    // Heading (and its Text child) should be gone
    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");

    // Should have 3 nodes: Root + Paragraph + Text(World)
    assert_eq!(reachable.len(), 3);
}

/// Plugin 1 removes the heading. Plugin 2 records every node it is dispatched.
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);
}