plushie-core 0.7.1

Core types and protocol for Plushie (no iced dependency)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
//! Composable tree walker.
//!
//! A [`TreeTransform`] encapsulates one pass over a [`TreeNode`] tree.
//! [`walk`] drives one or more transforms through a single depth-first
//! traversal, invoking `enter` on descent and `exit` on ascent. New
//! passes land as additional transforms instead of additional walks.
//!
//! # Why a single walker?
//!
//! The Rust SDK and the renderer each used to walk the retained tree
//! multiple times per frame (widget expansion, ID normalization, widget
//! state preparation, animation scanning). Every pass paid a fresh
//! traversal cost and every new pass added another walk. The walker
//! consolidates all mutation and observation into one traversal while
//! keeping individual concerns isolated behind the `TreeTransform`
//! trait.
//!
//! # Responsibilities
//!
//! The walker itself is intentionally narrow:
//!
//! - Depth-first recursion over `node.children`.
//! - `enter` before descending, `exit` after (in reverse order, so
//!   transforms can reliably pair setup and teardown like stack frames).
//! - `skip_children` to short-circuit descent.
//!
//! The walker does not manage scope strings, diagnostic buckets, or
//! transform-specific state. Each [`TreeTransform`] owns whatever
//! state it needs; shared fields (currently `scope`, `window_id`,
//! `warnings`) live on [`WalkCtx`] and are explicitly typed rather
//! than stashed behind `Any`. Keep [`WalkCtx`] small - add fields
//! only when a second transform actually needs to read the same
//! value the first transform writes.
//!
//! # Composition example
//!
//! ```ignore
//! use plushie_core::tree_walk::{TreeTransform, WalkCtx, walk};
//! use plushie_core::protocol::TreeNode;
//!
//! struct Normalize { /* ... */ }
//! struct Prepare<'a> { widget_state: &'a mut WidgetStateStore }
//!
//! impl TreeTransform for Normalize { /* ... */ }
//! impl TreeTransform for Prepare<'_> { /* ... */ }
//!
//! fn run(tree: &mut TreeNode, store: &mut WidgetStateStore) -> Vec<String> {
//!     let mut normalize = Normalize { /* ... */ };
//!     let mut prepare = Prepare { widget_state: store };
//!     let mut ctx = WalkCtx::default();
//!     walk(
//!         tree,
//!         &mut [&mut normalize, &mut prepare],
//!         &mut ctx,
//!     );
//!     ctx.warnings
//! }
//! ```
//!
//! Transforms are given a `&mut [&mut dyn TreeTransform]` slice so
//! they can mutate their own state while the walker iterates. Each
//! transform typically borrows whatever mutable state it needs (a
//! registry, a state store, a manager) with a lifetime that outlives
//! the walk.
//!
//! # Current consumers
//!
//! - `plushie` SDK: `runtime::prepare_tree` composes widget expansion
//!   (`ExpandWidgetsTransform`) and ID normalization
//!   (`NormalizeTransform`) in one walk.
//! - `plushie-widget-sdk`: `WidgetRegistry::prepare_and_scan` composes
//!   widget prepare (`PrepareTransform`) with animation-descriptor
//!   detection (`ScanTransform`) in one walk.

use crate::protocol::TreeNode;

/// Shared context threaded through a tree walk.
///
/// Fields here must be read or written by more than one transform. If
/// only one transform needs the data, keep it inside that transform
/// instead.
#[derive(Debug, Default, Clone)]
pub struct WalkCtx {
    /// Current scope chain for ID and a11y-ref rewriting. Normalize
    /// pushes its scope segment in `enter` and pops it in `exit`;
    /// downstream transforms read this to compute scoped IDs.
    pub scope: String,

    /// ID of the window subtree currently being walked. Window nodes
    /// set this in `enter` and clear it in `exit`.
    pub window_id: String,

    /// Diagnostic messages accumulated during the walk. Each entry is
    /// a typed [`crate::Diagnostic`]; call sites that need the legacy
    /// string form can map through `Display`.
    pub warnings: Vec<crate::Diagnostic>,

    /// Current descent depth, maintained by [`walk`]. Zero at the
    /// root; incremented on descent into children, decremented on
    /// ascent. Transforms can read this when they need depth-aware
    /// behaviour, but the depth cap is enforced centrally by the
    /// walker.
    pub depth: usize,
}

/// Maximum descent depth the walker will traverse. Nodes beyond this
/// depth are skipped with a `tree_depth_exceeded` warning. Mirrors
/// `plushie-widget-sdk::shared_state::MAX_TREE_DEPTH` so defence-in-
/// depth stays consistent: the walker halts descent, the widget
/// registry pruning sees the warning, and the SDK snapshot path bails
/// with the same diagnostic.
///
/// 256 is generous; real UI trees rarely exceed 20-30 levels.
pub const MAX_TREE_DEPTH: usize = 256;

/// A single pass over a tree node.
///
/// Implementors observe or mutate each node during traversal. Use
/// `enter` for pre-order work (e.g. scope push, node rewrite),
/// `exit` for post-order work (e.g. scope pop, cleanup tracking),
/// and `skip_children` to prune the traversal.
///
/// Invocation order for `exit` is reversed relative to `enter` so
/// paired push/pop semantics nest correctly when multiple transforms
/// share [`WalkCtx`] state.
pub trait TreeTransform {
    /// Called before descending into `node.children`.
    fn enter(&mut self, node: &mut TreeNode, ctx: &mut WalkCtx);

    /// Called after returning from `node.children`. Default is a no-op.
    fn exit(&mut self, _node: &mut TreeNode, _ctx: &mut WalkCtx) {}

    /// If any transform returns true, the walker skips this node's
    /// children. `exit` still runs for all transforms. Default `false`.
    fn skip_children(&self, _node: &TreeNode, _ctx: &WalkCtx) -> bool {
        false
    }
}

/// Drive `transforms` over the subtree rooted at `node`.
///
/// Order of operations per node:
///
/// 1. Each transform's `enter` is called in slice order.
/// 2. If any transform reports `skip_children`, child recursion is
///    skipped; otherwise the walker recurses into `node.children`.
/// 3. Each transform's `exit` is called in reverse slice order.
///
/// The walker also enforces a single depth cap ([`MAX_TREE_DEPTH`]).
/// If `ctx.depth` reaches the cap before descending, children are not
/// walked and a `tree_depth_exceeded` warning is appended to
/// `ctx.warnings` exactly once per overflowing subtree. Transforms
/// still run `enter` and `exit` on the current node so per-node state
/// like scope and factory maps stays consistent.
pub fn walk(node: &mut TreeNode, transforms: &mut [&mut dyn TreeTransform], ctx: &mut WalkCtx) {
    for t in transforms.iter_mut() {
        t.enter(node, ctx);
    }

    let transform_skip = transforms.iter().any(|t| t.skip_children(node, ctx));

    // Depth cap: once we're at the cap, refuse to descend. The cap is
    // enforced centrally so every transform benefits without needing
    // its own depth counter.
    let depth_cap_hit = ctx.depth >= MAX_TREE_DEPTH && !node.children.is_empty();
    if depth_cap_hit {
        ctx.warnings.push(crate::Diagnostic::TreeDepthExceeded {
            id: node.id.clone(),
            max_depth: MAX_TREE_DEPTH,
        });
    }

    if !transform_skip && !depth_cap_hit {
        // Children are walked in place. The walker does not manage
        // `ctx.scope` or `ctx.window_id`; transforms that care about
        // scope state push in `enter` and pop in `exit`.
        ctx.depth += 1;
        let child_count = node.children.len();
        for i in 0..child_count {
            walk(&mut node.children[i], transforms, ctx);
        }
        ctx.depth -= 1;
    }

    for t in transforms.iter_mut().rev() {
        t.exit(node, ctx);
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::protocol::TreeNode;

    // -- test helpers ------------------------------------------------------

    fn node(id: &str, children: Vec<TreeNode>) -> TreeNode {
        TreeNode {
            id: id.to_string(),
            type_name: "test".to_string(),
            props: crate::protocol::Props::default(),
            children,
        }
    }

    /// Records each `enter`/`exit` call on a shared trace.
    struct Recorder {
        name: &'static str,
        trace: std::rc::Rc<std::cell::RefCell<Vec<String>>>,
    }

    impl TreeTransform for Recorder {
        fn enter(&mut self, node: &mut TreeNode, _ctx: &mut WalkCtx) {
            self.trace
                .borrow_mut()
                .push(format!("{}:enter:{}", self.name, node.id));
        }
        fn exit(&mut self, node: &mut TreeNode, _ctx: &mut WalkCtx) {
            self.trace
                .borrow_mut()
                .push(format!("{}:exit:{}", self.name, node.id));
        }
    }

    /// Short-circuits descent when a node's id matches `skip_id`.
    struct Pruner {
        skip_id: &'static str,
    }

    impl TreeTransform for Pruner {
        fn enter(&mut self, _node: &mut TreeNode, _ctx: &mut WalkCtx) {}
        fn skip_children(&self, node: &TreeNode, _ctx: &WalkCtx) -> bool {
            node.id == self.skip_id
        }
    }

    #[test]
    fn enter_is_called_in_slice_order() {
        let trace = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
        let mut a = Recorder {
            name: "A",
            trace: trace.clone(),
        };
        let mut b = Recorder {
            name: "B",
            trace: trace.clone(),
        };
        let mut tree = node("root", vec![]);
        let mut ctx = WalkCtx::default();
        walk(&mut tree, &mut [&mut a, &mut b], &mut ctx);

        let t = trace.borrow();
        assert_eq!(t[0], "A:enter:root");
        assert_eq!(t[1], "B:enter:root");
    }

    #[test]
    fn exit_is_called_in_reverse_slice_order() {
        let trace = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
        let mut a = Recorder {
            name: "A",
            trace: trace.clone(),
        };
        let mut b = Recorder {
            name: "B",
            trace: trace.clone(),
        };
        let mut tree = node("root", vec![]);
        let mut ctx = WalkCtx::default();
        walk(&mut tree, &mut [&mut a, &mut b], &mut ctx);

        let t = trace.borrow();
        // The last two entries should be B's exit then A's exit
        // (reverse of enter order).
        assert_eq!(t[t.len() - 2], "B:exit:root");
        assert_eq!(t[t.len() - 1], "A:exit:root");
    }

    #[test]
    fn depth_first_traversal_enters_before_descending() {
        let trace = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
        let mut rec = Recorder {
            name: "R",
            trace: trace.clone(),
        };
        let mut tree = node(
            "root",
            vec![node("a", vec![node("a1", vec![])]), node("b", vec![])],
        );
        let mut ctx = WalkCtx::default();
        walk(&mut tree, &mut [&mut rec], &mut ctx);

        let t = trace.borrow();
        let expected = vec![
            "R:enter:root",
            "R:enter:a",
            "R:enter:a1",
            "R:exit:a1",
            "R:exit:a",
            "R:enter:b",
            "R:exit:b",
            "R:exit:root",
        ];
        assert_eq!(*t, expected);
    }

    #[test]
    fn skip_children_prunes_the_subtree_but_still_runs_exit() {
        let trace = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
        let mut rec = Recorder {
            name: "R",
            trace: trace.clone(),
        };
        let mut pruner = Pruner { skip_id: "a" };
        let mut tree = node(
            "root",
            vec![node("a", vec![node("a1", vec![])]), node("b", vec![])],
        );
        let mut ctx = WalkCtx::default();
        walk(&mut tree, &mut [&mut rec, &mut pruner], &mut ctx);

        let t = trace.borrow();
        // `a1` is never entered because pruner short-circuits at `a`.
        // `a`'s enter and exit both fire; subtree is skipped.
        assert!(!t.iter().any(|line| line.contains("a1")));
        assert!(t.contains(&"R:enter:a".to_string()));
        assert!(t.contains(&"R:exit:a".to_string()));
        assert!(t.contains(&"R:enter:b".to_string()));
    }

    #[test]
    fn transforms_can_mutate_nodes_in_enter() {
        struct Renamer;
        impl TreeTransform for Renamer {
            fn enter(&mut self, node: &mut TreeNode, _ctx: &mut WalkCtx) {
                node.id = format!("x:{}", node.id);
            }
        }

        let mut tree = node("root", vec![node("child", vec![])]);
        let mut r = Renamer;
        let mut ctx = WalkCtx::default();
        walk(&mut tree, &mut [&mut r], &mut ctx);

        assert_eq!(tree.id, "x:root");
        assert_eq!(tree.children[0].id, "x:child");
    }

    #[test]
    fn depth_cap_skips_subtree_with_diagnostic() {
        // Build a tree deeper than MAX_TREE_DEPTH. The walker must
        // not stack-overflow, must skip descent at the cap boundary,
        // and must emit a single tree_depth_exceeded warning.
        let mut leaf = node("leaf", vec![]);
        for i in 0..(MAX_TREE_DEPTH + 20) {
            leaf = node(&format!("n{i}"), vec![leaf]);
        }

        let trace = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
        let mut rec = Recorder {
            name: "R",
            trace: trace.clone(),
        };
        let mut ctx = WalkCtx::default();
        walk(&mut leaf, &mut [&mut rec], &mut ctx);

        // At least one warning, all of them the depth diagnostic.
        assert!(
            ctx.warnings
                .iter()
                .any(|w| matches!(w.kind(), crate::DiagnosticKind::TreeDepthExceeded)),
            "expected tree_depth_exceeded warning, got {:?}",
            ctx.warnings,
        );
        // The deepest "leaf" node must not have been entered: descent
        // stopped at the cap.
        assert!(
            !trace.borrow().iter().any(|line| line == "R:enter:leaf"),
            "walker descended past the cap"
        );
    }

    #[test]
    fn warnings_accumulate_across_transforms() {
        // Each Warner pushes an EmptyId diagnostic tagged by its name
        // in `type_name`. That lets the assertion assert both on count
        // and on which transform fired for which node without needing
        // a purpose-built variant.
        struct Warner(&'static str);
        impl TreeTransform for Warner {
            fn enter(&mut self, node: &mut TreeNode, ctx: &mut WalkCtx) {
                ctx.warnings.push(crate::Diagnostic::EmptyId {
                    type_name: format!("{}@{}", self.0, node.id),
                });
            }
        }

        let mut tree = node("root", vec![node("child", vec![])]);
        let mut a = Warner("A");
        let mut b = Warner("B");
        let mut ctx = WalkCtx::default();
        walk(&mut tree, &mut [&mut a, &mut b], &mut ctx);

        // Two warnings per node (one per transform), two nodes.
        assert_eq!(ctx.warnings.len(), 4);
        let tags: Vec<&str> = ctx
            .warnings
            .iter()
            .filter_map(|d| match d {
                crate::Diagnostic::EmptyId { type_name } => Some(type_name.as_str()),
                _ => None,
            })
            .collect();
        assert!(tags.contains(&"A@root"));
        assert!(tags.contains(&"B@root"));
        assert!(tags.contains(&"A@child"));
        assert!(tags.contains(&"B@child"));
    }
}