Skip to main content

satteri_plugin_api/
runner.rs

1use crate::commands::{BuiltNode, Command, NewNode};
2use crate::context::{Diagnostic, PluginContext, Severity};
3use crate::data::{DataMap, TypedDataMap};
4use crate::plugin::{NodeView, Plugin, VisitResult};
5use crate::typed_nodes::*;
6use satteri_arena::{Arena, ArenaBuilder, Mdast};
7use satteri_ast::mdast::MdastNodeType;
8use satteri_ast::patch::{Patch, PatchContent, apply_patches_strict};
9
10/// Result of running plugins against an arena.
11pub struct PluginRunResult {
12    /// The (possibly modified) arena, same instance if no mutations, patched in place if mutations occurred.
13    pub arena: Arena<Mdast>,
14    pub commands: Vec<Command>,
15    pub diagnostics: Vec<Diagnostic>,
16    pub has_mutations: bool,
17}
18
19/// Runs a list of Rust plugins sequentially against an arena.
20pub struct PluginRunner {
21    plugins: Vec<Box<dyn Plugin>>,
22}
23
24impl PluginRunner {
25    pub fn new(plugins: Vec<Box<dyn Plugin>>) -> Self {
26        Self { plugins }
27    }
28
29    /// Initialize all plugins (call init on each).
30    pub fn init(&mut self) {
31        for plugin in &mut self.plugins {
32            plugin.init();
33        }
34    }
35
36    /// Run all plugins against an arena. Returns the result.
37    pub fn run(
38        &mut self,
39        arena: Arena<Mdast>,
40        data_map: &mut DataMap,
41        typed_data: &mut TypedDataMap,
42    ) -> PluginRunResult {
43        let mut all_commands: Vec<Command> = Vec::new();
44        let mut all_diagnostics: Vec<Diagnostic> = Vec::new();
45        let mut current_arena = arena;
46
47        for plugin in &mut self.plugins {
48            let mut ctx = PluginContext::new(&current_arena, data_map, typed_data);
49
50            // Call before
51            plugin.before(&current_arena, &mut ctx);
52
53            // Root walk, not an id scan: in-place applies leave detached garbage in the arena
54            let mut stack: Vec<u32> = if current_arena.is_empty() {
55                Vec::new()
56            } else {
57                vec![0]
58            };
59            while let Some(node_id) = stack.pop() {
60                let node = current_arena.get_node(node_id);
61                let node_type_byte = node.node_type;
62
63                let result = dispatch_visitor(
64                    plugin.as_mut(),
65                    node_type_byte,
66                    node_id,
67                    &current_arena,
68                    &mut ctx,
69                );
70
71                match result {
72                    VisitResult::Replace(new_node) => {
73                        ctx.replace_node(node_id, new_node);
74                    }
75                    VisitResult::Remove => {
76                        ctx.remove_node(node_id);
77                    }
78                    VisitResult::NoChange => {}
79                }
80
81                for &child_id in current_arena.get_children(node_id).iter().rev() {
82                    stack.push(child_id);
83                }
84            }
85
86            // Call after
87            plugin.after(&current_arena, &mut ctx);
88
89            let (commands, diagnostics) = ctx.take_commands();
90            let has_cmds = !commands.is_empty();
91            all_diagnostics.extend(diagnostics);
92
93            if has_cmds {
94                let patches = commands_to_patches(commands.iter().collect(), &current_arena);
95                if !patches.is_empty()
96                    && let Err(err) = apply_patches_strict(&mut current_arena, &patches)
97                {
98                    all_diagnostics.push(Diagnostic {
99                        message: format!("invalid patch combination: {err}"),
100                        node_id: None,
101                        severity: Severity::Error,
102                    });
103                }
104                all_commands.extend(commands);
105            }
106            // else: skip optimization, current_arena passes through unchanged
107            // (Data mutations are already applied via data_map directly)
108        }
109
110        let has_mutations = !all_commands.is_empty();
111
112        PluginRunResult {
113            arena: current_arena,
114            commands: all_commands,
115            diagnostics: all_diagnostics,
116            has_mutations,
117        }
118    }
119}
120
121/// Convert a list of Commands into Patches.
122/// SetData commands are skipped (they are applied directly through the DataMap,
123/// not via arena structural mutation).
124/// NewNode::Raw commands are skipped (need parser, Phase 8).
125fn commands_to_patches(commands: Vec<&Command>, arena: &Arena<Mdast>) -> Vec<Patch<Mdast>> {
126    commands
127        .into_iter()
128        .filter_map(|cmd| match cmd {
129            Command::Replace { node_id, new_node } => {
130                built_node_to_arena(new_node, arena.string_pool()).map(|sub| Patch::Replace {
131                    node_id: *node_id,
132                    new_tree: PatchContent::Tree(sub),
133                    keep_children: false,
134                })
135            }
136            Command::Remove { node_id } => Some(Patch::Remove { node_id: *node_id }),
137            Command::InsertBefore { node_id, new_node } => {
138                built_node_to_arena(new_node, arena.string_pool()).map(|sub| Patch::InsertBefore {
139                    node_id: *node_id,
140                    new_tree: PatchContent::Tree(sub),
141                })
142            }
143            Command::InsertAfter { node_id, new_node } => {
144                built_node_to_arena(new_node, arena.string_pool()).map(|sub| Patch::InsertAfter {
145                    node_id: *node_id,
146                    new_tree: PatchContent::Tree(sub),
147                })
148            }
149            Command::Wrap {
150                node_id,
151                parent_node,
152            } => built_node_to_arena(parent_node, arena.string_pool()).map(|sub| Patch::Wrap {
153                node_id: *node_id,
154                parent_tree: PatchContent::Tree(sub),
155            }),
156            Command::PrependChild {
157                node_id,
158                child_node,
159            } => built_node_to_arena(child_node, arena.string_pool()).map(|sub| {
160                Patch::PrependChild {
161                    node_id: *node_id,
162                    child_tree: PatchContent::Tree(sub),
163                }
164            }),
165            Command::AppendChild {
166                node_id,
167                child_node,
168            } => {
169                built_node_to_arena(child_node, arena.string_pool()).map(|sub| Patch::AppendChild {
170                    node_id: *node_id,
171                    child_tree: PatchContent::Tree(sub),
172                })
173            }
174            Command::SetData { .. } => {
175                // Already applied via DataMap in PluginContext, no arena mutation needed
176                None
177            }
178        })
179        .collect()
180}
181
182/// Convert a NewNode into a mini Arena for use as a patch sub-tree.
183/// Returns None for Raw nodes (parser integration is Phase 8).
184fn built_node_to_arena(new_node: &NewNode, string_pool: &str) -> Option<Arena<Mdast>> {
185    match new_node {
186        NewNode::Raw(_) => None, // Phase 8
187        NewNode::Built(built) => {
188            let mut builder = ArenaBuilder::<Mdast>::new(string_pool.to_string());
189            emit_built_node(built, &mut builder);
190            Some(builder.finish())
191        }
192    }
193}
194
195/// Recursively emit a BuiltNode into the builder.
196fn emit_built_node(built: &BuiltNode, builder: &mut ArenaBuilder<Mdast>) {
197    builder.open_node(built.node_type as u8);
198    if !built.data_bytes.is_empty() {
199        builder.set_data_current(&built.data_bytes);
200    }
201    for child in &built.children {
202        match child {
203            NewNode::Built(child_built) => emit_built_node(child_built, builder),
204            NewNode::Raw(_) => {} // skip
205        }
206    }
207    builder.close_node();
208}
209
210/// Dispatch a node to the appropriate typed visitor method.
211/// Returns VisitResult from the plugin.
212fn dispatch_visitor(
213    plugin: &mut dyn Plugin,
214    node_type_byte: u8,
215    node_id: u32,
216    arena: &Arena<Mdast>,
217    ctx: &mut PluginContext,
218) -> VisitResult {
219    match MdastNodeType::from_u8(node_type_byte) {
220        Some(MdastNodeType::Heading) => plugin.visit_heading(&Heading { node_id, arena }, ctx),
221        Some(MdastNodeType::Paragraph) => {
222            plugin.visit_paragraph(&Paragraph { node_id, arena }, ctx)
223        }
224        Some(MdastNodeType::Text) => plugin.visit_text(&Text { node_id, arena }, ctx),
225        Some(MdastNodeType::Link) => plugin.visit_link(&Link { node_id, arena }, ctx),
226        Some(MdastNodeType::Image) => plugin.visit_image(&Image { node_id, arena }, ctx),
227        Some(MdastNodeType::Code) => plugin.visit_code(&Code { node_id, arena }, ctx),
228        Some(MdastNodeType::List) => plugin.visit_list(&NodeView { node_id, arena }, ctx),
229        Some(MdastNodeType::ListItem) => plugin.visit_list_item(&NodeView { node_id, arena }, ctx),
230        Some(MdastNodeType::Blockquote) => {
231            plugin.visit_blockquote(&NodeView { node_id, arena }, ctx)
232        }
233        Some(MdastNodeType::Emphasis) => plugin.visit_emphasis(&NodeView { node_id, arena }, ctx),
234        Some(MdastNodeType::Strong) => plugin.visit_strong(&NodeView { node_id, arena }, ctx),
235        Some(MdastNodeType::InlineCode) => plugin.visit_inline_code(&Text { node_id, arena }, ctx),
236        Some(MdastNodeType::Html) => plugin.visit_html(&Text { node_id, arena }, ctx),
237        Some(MdastNodeType::Table) => plugin.visit_table(&NodeView { node_id, arena }, ctx),
238        _ => VisitResult::NoChange,
239    }
240}