Skip to main content

wallr_core/custom_effects/
mod.rs

1//! Sandboxed declarative custom-effect validation and WGSL transpilation.
2
3use serde::{Deserialize, Serialize};
4use std::collections::{HashMap, HashSet};
5
6/// A declarative per-pixel effect definition.
7#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
8pub struct CustomEffect {
9    /// Effect parameters and their default numeric values.
10    #[serde(default)]
11    pub params: HashMap<String, serde_yaml::Value>,
12    /// Sandboxed expression body returning a color expression.
13    pub field: String,
14}
15
16/// Error returned when a custom field cannot be safely transpiled.
17#[derive(Debug, thiserror::Error, PartialEq)]
18pub enum CustomEffectError {
19    #[error("custom effect field is empty")]
20    Empty,
21    #[error("unknown function '{0}'")]
22    UnknownFunction(String),
23    #[error("undefined variable '{0}'")]
24    UndefinedVariable(String),
25    #[error("unsupported custom-effect construct '{0}'")]
26    Unsupported(String),
27}
28
29const FUNCTIONS: &[&str] = &[
30    "mix",
31    "clamp",
32    "floor",
33    "ceil",
34    "length",
35    "hash",
36    "noise",
37    "smoothstep",
38    "sin",
39    "cos",
40    "tan",
41    "abs",
42    "min",
43    "max",
44    "pow",
45    "direction_vector",
46    "sample",
47    "vec2",
48];
49const CONTEXT: &[&str] = &["t", "uv", "resolution", "old", "new", "time_absolute", "pi"];
50
51/// Validate and transpile a field into a WGSL expression body.
52pub fn transpile(name: &str, effect: &CustomEffect) -> Result<String, CustomEffectError> {
53    if effect.field.trim().is_empty() {
54        return Err(CustomEffectError::Empty);
55    }
56    let tokens = tokenize(&effect.field);
57    let mut declared: HashSet<String> = effect.params.keys().cloned().collect();
58    declared.extend(CONTEXT.iter().map(|s| s.to_string()));
59    for (index, token) in tokens.iter().enumerate() {
60        if !is_identifier(token) {
61            continue;
62        }
63        let is_call = token == "vec2"
64            || tokens
65                .get(index + 1)
66                .is_some_and(|next| next == "(" || next == "<");
67        if is_call {
68            if !FUNCTIONS.contains(&token.as_str())
69                && token != "f32"
70                && token != "return"
71                && token != "let"
72            {
73                return Err(CustomEffectError::UnknownFunction(token.clone()));
74            }
75        } else if !declared.contains(token)
76            && token != "return"
77            && token != "let"
78            && token != "color"
79            && token != "shard"
80            && token != "delay"
81            && token != "local_t"
82            && token != "offset"
83            && token != "alpha"
84            && token != "f32"
85        {
86            // Assignment names are declared by the simple `name = expression` form.
87            let assignment = tokens.get(index + 1).is_some_and(|next| next == "=");
88            if assignment {
89                declared.insert(token.clone());
90            } else {
91                return Err(CustomEffectError::UndefinedVariable(token.clone()));
92            }
93        }
94    }
95    if effect.field.contains('{')
96        || effect.field.contains('}')
97        || effect.field.contains("for ")
98        || effect.field.contains("while ")
99    {
100        return Err(CustomEffectError::Unsupported(
101            "loops or block delimiters".into(),
102        ));
103    }
104    Ok(format!("// custom effect: {name}\n{}", effect.field))
105}
106
107fn tokenize(source: &str) -> Vec<String> {
108    let mut tokens = Vec::new();
109    let mut current = String::new();
110    for c in source.chars() {
111        if c.is_ascii_alphanumeric() || c == '_' || c == '.' {
112            current.push(c);
113        } else {
114            if !current.is_empty() {
115                tokens.push(std::mem::take(&mut current));
116            }
117            if c == '(' || c == '=' {
118                tokens.push(c.to_string());
119            }
120        }
121    }
122    if !current.is_empty() {
123        tokens.push(current);
124    }
125    tokens
126}
127fn is_identifier(token: &str) -> bool {
128    token
129        .chars()
130        .next()
131        .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
132        && token.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138    #[test]
139    fn transpiles_valid_field() {
140        let effect = CustomEffect {
141            params: HashMap::new(),
142            field: "return mix(old, new, t)".into(),
143        };
144        assert!(transpile("fade", &effect).is_ok());
145    }
146    #[test]
147    fn rejects_unknown_names() {
148        let effect = CustomEffect {
149            params: HashMap::new(),
150            field: "return explode(old)".into(),
151        };
152        assert!(matches!(
153            transpile("bad", &effect),
154            Err(CustomEffectError::UnknownFunction(_))
155        ));
156    }
157}