rlx_core/render/scenes/lines/grammar.rs
1//! L-system grammar expansion: pure, deterministic string rewriting. Applies a
2//! production rule set to an axiom `depth` times — each character is replaced by
3//! its successor (or kept if no rule matches). This is a build-time step (runs
4//! inside `Scene::configure`, off the hot path), not per-frame work.
5//!
6//! Deterministic by construction: a fixed `(axiom, rules, depth)` always yields
7//! the exact same string (NFR 6), which is what makes it directly unit-testable.
8
9// Under render/, so it carries the hygiene guard's panic pragma even though it
10// runs only at preset load — written allocation-tolerant but panic-free.
11#![deny(
12 clippy::unwrap_used,
13 clippy::expect_used,
14 clippy::indexing_slicing,
15 clippy::panic,
16 clippy::unreachable
17)]
18
19/// Expand `axiom` by applying `rules` `depth` times. Each rule is
20/// `(predecessor, successor)`; a character with no matching rule maps to itself
21/// (the standard context-free L-system semantics). Pure and deterministic.
22///
23/// Runs only at preset load. Growth is bounded by the caller clamping `depth`
24/// (see `MAX_LSYSTEM_DEPTH`); the turtle then caps the segment count.
25pub fn expand(axiom: &str, rules: &[(char, String)], depth: u32) -> String {
26 let mut current = axiom.to_string();
27 for _ in 0..depth {
28 let mut next = String::with_capacity(current.len().saturating_mul(2));
29 for ch in current.chars() {
30 match rules.iter().find(|(pred, _)| *pred == ch) {
31 Some((_, succ)) => next.push_str(succ),
32 None => next.push(ch),
33 }
34 }
35 current = next;
36 }
37 current
38}
39
40#[cfg(test)]
41mod tests {
42 use super::*;
43
44 #[test]
45 fn expand_is_exact_and_deterministic() {
46 // Fibonacci-word grammar: trivially hand-verifiable exact strings.
47 let rules = [('A', "AB".to_string()), ('B', "A".to_string())];
48 assert_eq!(expand("A", &rules, 0), "A");
49 assert_eq!(expand("A", &rules, 1), "AB");
50 assert_eq!(expand("A", &rules, 2), "ABA");
51 assert_eq!(expand("A", &rules, 3), "ABAAB");
52 assert_eq!(expand("A", &rules, 5), "ABAABABAABAAB");
53
54 // The canonical example: Koch edge F -> "F+F--F+F".
55 let koch = [('F', "F+F--F+F".to_string())];
56 assert_eq!(expand("F", &koch, 1), "F+F--F+F");
57 assert_eq!(
58 expand("F", &koch, 2),
59 "F+F--F+F+F+F--F+F--F+F--F+F+F+F--F+F"
60 );
61
62 // Determinism: the same inputs twice are identical.
63 assert_eq!(expand("F", &koch, 3), expand("F", &koch, 3));
64 }
65
66 #[test]
67 fn characters_without_a_rule_pass_through() {
68 // `+`, `-`, `[`, `]` have no rules and survive verbatim; only `X`/`F`
69 // rewrite. A single expansion of the classic plant axiom.
70 let rules = [('X', "F[+X]F".to_string()), ('F', "FF".to_string())];
71 assert_eq!(expand("X", &rules, 1), "F[+X]F");
72 assert_eq!(expand("X", &rules, 2), "FF[+F[+X]F]FF");
73 }
74}