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 } => built_node_to_arena(new_node, arena.source())
128                .map(|sub| Patch::Replace {
129                    node_id: *node_id,
130                    new_tree: sub,
131                    keep_children: false,
132                }),
133            Command::Remove { node_id } => Some(Patch::Remove { node_id: *node_id }),
134            Command::InsertBefore { node_id, new_node } => {
135                built_node_to_arena(new_node, arena.source()).map(|sub| Patch::InsertBefore {
136                    node_id: *node_id,
137                    new_tree: sub,
138                })
139            }
140            Command::InsertAfter { node_id, new_node } => {
141                built_node_to_arena(new_node, arena.source()).map(|sub| Patch::InsertAfter {
142                    node_id: *node_id,
143                    new_tree: sub,
144                })
145            }
146            Command::Wrap {
147                node_id,
148                parent_node,
149            } => built_node_to_arena(parent_node, arena.source()).map(|sub| Patch::Wrap {
150                node_id: *node_id,
151                parent_tree: sub,
152            }),
153            Command::PrependChild {
154                node_id,
155                child_node,
156            } => built_node_to_arena(child_node, arena.source()).map(|sub| Patch::PrependChild {
157                node_id: *node_id,
158                child_tree: sub,
159            }),
160            Command::AppendChild {
161                node_id,
162                child_node,
163            } => built_node_to_arena(child_node, arena.source()).map(|sub| Patch::AppendChild {
164                node_id: *node_id,
165                child_tree: sub,
166            }),
167            Command::SetData { .. } => {
168                // Already applied via DataMap in PluginContext, no arena rebuild needed
169                None
170            }
171        })
172        .collect()
173}
174
175/// Convert a NewNode into a mini Arena for use as a patch sub-tree.
176/// Returns None for Raw nodes (parser integration is Phase 8).
177fn built_node_to_arena(new_node: &NewNode, source: &str) -> Option<Arena<Mdast>> {
178    match new_node {
179        NewNode::Raw(_) => None, // Phase 8
180        NewNode::Built(built) => {
181            let mut builder = ArenaBuilder::<Mdast>::new(source.to_string());
182            emit_built_node(built, &mut builder);
183            Some(builder.finish())
184        }
185    }
186}
187
188/// Recursively emit a BuiltNode into the builder.
189fn emit_built_node(built: &BuiltNode, builder: &mut ArenaBuilder<Mdast>) {
190    builder.open_node(built.node_type as u8);
191    if !built.data_bytes.is_empty() {
192        builder.set_data_current(&built.data_bytes);
193    }
194    for child in &built.children {
195        match child {
196            NewNode::Built(child_built) => emit_built_node(child_built, builder),
197            NewNode::Raw(_) => {} // skip
198        }
199    }
200    builder.close_node();
201}
202
203/// Dispatch a node to the appropriate typed visitor method.
204/// Returns VisitResult from the plugin.
205fn dispatch_visitor(
206    plugin: &mut dyn Plugin,
207    node_type_byte: u8,
208    node_id: u32,
209    arena: &Arena<Mdast>,
210    ctx: &mut PluginContext,
211) -> VisitResult {
212    match MdastNodeType::from_u8(node_type_byte) {
213        Some(MdastNodeType::Heading) => plugin.visit_heading(&Heading { node_id, arena }, ctx),
214        Some(MdastNodeType::Paragraph) => {
215            plugin.visit_paragraph(&Paragraph { node_id, arena }, ctx)
216        }
217        Some(MdastNodeType::Text) => plugin.visit_text(&Text { node_id, arena }, ctx),
218        Some(MdastNodeType::Link) => plugin.visit_link(&Link { node_id, arena }, ctx),
219        Some(MdastNodeType::Image) => plugin.visit_image(&Image { node_id, arena }, ctx),
220        Some(MdastNodeType::Code) => plugin.visit_code(&Code { node_id, arena }, ctx),
221        Some(MdastNodeType::List) => plugin.visit_list(&NodeView { node_id, arena }, ctx),
222        Some(MdastNodeType::ListItem) => plugin.visit_list_item(&NodeView { node_id, arena }, ctx),
223        Some(MdastNodeType::Blockquote) => {
224            plugin.visit_blockquote(&NodeView { node_id, arena }, ctx)
225        }
226        Some(MdastNodeType::Emphasis) => plugin.visit_emphasis(&NodeView { node_id, arena }, ctx),
227        Some(MdastNodeType::Strong) => plugin.visit_strong(&NodeView { node_id, arena }, ctx),
228        Some(MdastNodeType::InlineCode) => plugin.visit_inline_code(&Text { node_id, arena }, ctx),
229        Some(MdastNodeType::Html) => plugin.visit_html(&Text { node_id, arena }, ctx),
230        Some(MdastNodeType::Table) => plugin.visit_table(&NodeView { node_id, arena }, ctx),
231        _ => VisitResult::NoChange,
232    }
233}