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::rebuild::{rebuild, Patch};
9
10/// Result of running plugins against an arena.
11pub struct PluginRunResult {
12    /// The (possibly modified) arena, same instance if no mutations, rebuilt 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            // Walk the arena depth-first, dispatch to typed visitor methods
54            let node_count = current_arena.len() as u32;
55            for node_id in 0..node_count {
56                let node = current_arena.get_node(node_id);
57                let node_type_byte = node.node_type;
58
59                let result = dispatch_visitor(
60                    plugin.as_mut(),
61                    node_type_byte,
62                    node_id,
63                    &current_arena,
64                    &mut ctx,
65                );
66
67                match result {
68                    VisitResult::Replace(new_node) => {
69                        ctx.replace_node(node_id, new_node);
70                    }
71                    VisitResult::Remove => {
72                        ctx.remove_node(node_id);
73                    }
74                    VisitResult::NoChange => {}
75                }
76            }
77
78            // Call after
79            plugin.after(&current_arena, &mut ctx);
80
81            let (commands, diagnostics) = ctx.take_commands();
82            let has_cmds = !commands.is_empty();
83            all_diagnostics.extend(diagnostics);
84
85            if has_cmds {
86                // Convert commands to patches and rebuild the arena
87                let patches = commands_to_patches(commands.iter().collect(), &current_arena);
88                if !patches.is_empty() {
89                    match rebuild(&current_arena, &patches) {
90                        Ok(rebuilt) => current_arena = rebuilt,
91                        Err(err) => {
92                            // Drop the rebuild for this plugin's pass and surface
93                            // the bad combination so the plugin author can fix it.
94                            all_diagnostics.push(Diagnostic {
95                                message: format!("invalid patch combination: {err}"),
96                                node_id: None,
97                                severity: Severity::Error,
98                            });
99                        }
100                    }
101                }
102                all_commands.extend(commands);
103            }
104            // else: skip optimization, current_arena passes through unchanged
105            // (Data mutations are already applied via data_map directly)
106        }
107
108        let has_mutations = !all_commands.is_empty();
109
110        PluginRunResult {
111            arena: current_arena,
112            commands: all_commands,
113            diagnostics: all_diagnostics,
114            has_mutations,
115        }
116    }
117}
118
119/// Convert a list of Commands into Patches.
120/// SetData commands are skipped (they are applied directly through the DataMap,
121/// not via arena structural mutation).
122/// NewNode::Raw commands are skipped (need parser, Phase 8).
123fn commands_to_patches(commands: Vec<&Command>, arena: &Arena<Mdast>) -> Vec<Patch<Mdast>> {
124    commands
125        .into_iter()
126        .filter_map(|cmd| match cmd {
127            Command::Replace { node_id, new_node } => {
128                built_node_to_arena(new_node, arena.string_pool()).map(|sub| Patch::Replace {
129                    node_id: *node_id,
130                    new_tree: sub,
131                    keep_children: false,
132                })
133            }
134            Command::Remove { node_id } => Some(Patch::Remove { node_id: *node_id }),
135            Command::InsertBefore { node_id, new_node } => {
136                built_node_to_arena(new_node, arena.string_pool()).map(|sub| Patch::InsertBefore {
137                    node_id: *node_id,
138                    new_tree: sub,
139                })
140            }
141            Command::InsertAfter { node_id, new_node } => {
142                built_node_to_arena(new_node, arena.string_pool()).map(|sub| Patch::InsertAfter {
143                    node_id: *node_id,
144                    new_tree: sub,
145                })
146            }
147            Command::Wrap {
148                node_id,
149                parent_node,
150            } => built_node_to_arena(parent_node, arena.string_pool()).map(|sub| Patch::Wrap {
151                node_id: *node_id,
152                parent_tree: sub,
153            }),
154            Command::PrependChild {
155                node_id,
156                child_node,
157            } => built_node_to_arena(child_node, arena.string_pool()).map(|sub| {
158                Patch::PrependChild {
159                    node_id: *node_id,
160                    child_tree: sub,
161                }
162            }),
163            Command::AppendChild {
164                node_id,
165                child_node,
166            } => {
167                built_node_to_arena(child_node, arena.string_pool()).map(|sub| Patch::AppendChild {
168                    node_id: *node_id,
169                    child_tree: sub,
170                })
171            }
172            Command::SetData { .. } => {
173                // Already applied via DataMap in PluginContext, no arena rebuild needed
174                None
175            }
176        })
177        .collect()
178}
179
180/// Convert a NewNode into a mini Arena for use as a patch sub-tree.
181/// Returns None for Raw nodes (parser integration is Phase 8).
182fn built_node_to_arena(new_node: &NewNode, string_pool: &str) -> Option<Arena<Mdast>> {
183    match new_node {
184        NewNode::Raw(_) => None, // Phase 8
185        NewNode::Built(built) => {
186            let mut builder = ArenaBuilder::<Mdast>::new(string_pool.to_string());
187            emit_built_node(built, &mut builder);
188            Some(builder.finish())
189        }
190    }
191}
192
193/// Recursively emit a BuiltNode into the builder.
194fn emit_built_node(built: &BuiltNode, builder: &mut ArenaBuilder<Mdast>) {
195    builder.open_node(built.node_type as u8);
196    if !built.data_bytes.is_empty() {
197        builder.set_data_current(&built.data_bytes);
198    }
199    for child in &built.children {
200        match child {
201            NewNode::Built(child_built) => emit_built_node(child_built, builder),
202            NewNode::Raw(_) => {} // skip
203        }
204    }
205    builder.close_node();
206}
207
208/// Dispatch a node to the appropriate typed visitor method.
209/// Returns VisitResult from the plugin.
210fn dispatch_visitor(
211    plugin: &mut dyn Plugin,
212    node_type_byte: u8,
213    node_id: u32,
214    arena: &Arena<Mdast>,
215    ctx: &mut PluginContext,
216) -> VisitResult {
217    match MdastNodeType::from_u8(node_type_byte) {
218        Some(MdastNodeType::Heading) => plugin.visit_heading(&Heading { node_id, arena }, ctx),
219        Some(MdastNodeType::Paragraph) => {
220            plugin.visit_paragraph(&Paragraph { node_id, arena }, ctx)
221        }
222        Some(MdastNodeType::Text) => plugin.visit_text(&Text { node_id, arena }, ctx),
223        Some(MdastNodeType::Link) => plugin.visit_link(&Link { node_id, arena }, ctx),
224        Some(MdastNodeType::Image) => plugin.visit_image(&Image { node_id, arena }, ctx),
225        Some(MdastNodeType::Code) => plugin.visit_code(&Code { node_id, arena }, ctx),
226        Some(MdastNodeType::List) => plugin.visit_list(&NodeView { node_id, arena }, ctx),
227        Some(MdastNodeType::ListItem) => plugin.visit_list_item(&NodeView { node_id, arena }, ctx),
228        Some(MdastNodeType::Blockquote) => {
229            plugin.visit_blockquote(&NodeView { node_id, arena }, ctx)
230        }
231        Some(MdastNodeType::Emphasis) => plugin.visit_emphasis(&NodeView { node_id, arena }, ctx),
232        Some(MdastNodeType::Strong) => plugin.visit_strong(&NodeView { node_id, arena }, ctx),
233        Some(MdastNodeType::InlineCode) => plugin.visit_inline_code(&Text { node_id, arena }, ctx),
234        Some(MdastNodeType::Html) => plugin.visit_html(&Text { node_id, arena }, ctx),
235        Some(MdastNodeType::Table) => plugin.visit_table(&NodeView { node_id, arena }, ctx),
236        _ => VisitResult::NoChange,
237    }
238}