Skip to main content

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}