Skip to main content

wallr_core/custom_effects/
mod.rs

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