wallr_core/custom_effects/
mod.rs1use serde::{Deserialize, Serialize};
4use std::collections::{HashMap, HashSet};
5
6#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
8pub struct CustomEffect {
9 #[serde(default)]
11 pub params: HashMap<String, serde_yaml::Value>,
12 pub field: String,
14}
15
16#[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
51pub 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 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}