wallr_core/custom_effects/
mod.rs1use 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 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}