Skip to main content

variable_codegen/
typescript.rs

1use crate::template::{
2    self, FeatureContext, FileContext, StructContext, StructFieldContext, VariableContext,
3};
4use variable_core::ast::{Value, VarFile, VarType};
5
6const TEMPLATE: &str = include_str!("templates/typescript.ts.j2");
7
8fn to_camel_case(s: &str) -> String {
9    let mut result = String::new();
10    let mut capitalize_next = false;
11    for (i, c) in s.chars().enumerate() {
12        if c == '_' {
13            capitalize_next = true;
14        } else if capitalize_next {
15            result.push(c.to_uppercase().next().unwrap());
16            capitalize_next = false;
17        } else if i == 0 {
18            result.push(c.to_lowercase().next().unwrap());
19        } else {
20            result.push(c);
21        }
22    }
23    result
24}
25
26fn ts_type(var_type: &VarType) -> String {
27    match var_type {
28        VarType::Boolean => "boolean".to_string(),
29        VarType::Integer => "number".to_string(),
30        VarType::Float => "number".to_string(),
31        VarType::String => "string".to_string(),
32        VarType::Struct(name) => name.clone(),
33    }
34}
35
36fn ts_value(value: &Value, var_file: &VarFile) -> String {
37    match value {
38        Value::Boolean(b) => b.to_string(),
39        Value::Integer(n) => format!("{}", n),
40        Value::Float(n) => {
41            if *n == (*n as i64) as f64 {
42                format!("{}", *n as i64)
43            } else {
44                format!("{}", n)
45            }
46        }
47        Value::String(s) => {
48            let escaped = s
49                .replace('\\', "\\\\")
50                .replace('"', "\\\"")
51                .replace('\n', "\\n")
52                .replace('\r', "\\r")
53                .replace('\t', "\\t");
54            format!("\"{}\"", escaped)
55        }
56        Value::Struct {
57            struct_name,
58            fields,
59        } => {
60            // Resolve all fields (merge struct defaults with overrides)
61            let struct_def = var_file.structs.iter().find(|s| &s.name == struct_name);
62            let mut all_fields = Vec::new();
63            if let Some(def) = struct_def {
64                for field in &def.fields {
65                    let val = fields.get(&field.name).unwrap_or(&field.default);
66                    all_fields.push(format!("    {}: {}", field.name, ts_value(val, var_file)));
67                }
68            } else {
69                // No definition found; just render the provided fields
70                for (name, val) in fields {
71                    all_fields.push(format!("    {}: {}", name, ts_value(val, var_file)));
72                }
73            }
74            if all_fields.is_empty() {
75                "{}".to_string()
76            } else {
77                format!("{{\n{},\n  }}", all_fields.join(",\n"))
78            }
79        }
80    }
81}
82
83fn build_context(var_file: &VarFile) -> FileContext {
84    let structs = var_file
85        .structs
86        .iter()
87        .map(|struct_def| StructContext {
88            name: struct_def.name.clone(),
89            fields: struct_def
90                .fields
91                .iter()
92                .map(|field| StructFieldContext {
93                    name: field.name.clone(),
94                    type_name: ts_type(&field.field_type),
95                })
96                .collect(),
97        })
98        .collect();
99
100    let features = var_file
101        .features
102        .iter()
103        .map(|feature| {
104            let camel_name = to_camel_case(&feature.name);
105            FeatureContext {
106                name: feature.name.clone(),
107                id: feature.id,
108                interface_name: format!("{}Variables", feature.name),
109                defaults_name: format!("{}Defaults", camel_name),
110                variable_ids_name: format!("{}VariableIds", camel_name),
111                fn_name: format!("get{}Variables", feature.name),
112                variables: feature
113                    .variables
114                    .iter()
115                    .map(|var| VariableContext {
116                        name: var.name.clone(),
117                        type_name: ts_type(&var.var_type),
118                        default_value: ts_value(&var.default, var_file),
119                        id: var.id,
120                    })
121                    .collect(),
122            }
123        })
124        .collect();
125
126    FileContext { structs, features }
127}
128
129pub fn generate_typescript(var_file: &VarFile) -> String {
130    let context = build_context(var_file);
131    template::render(TEMPLATE, &context)
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137    use variable_core::parse_and_validate;
138
139    fn generate(input: &str) -> String {
140        let var_file = parse_and_validate(input).expect("parse failed");
141        generate_typescript(&var_file)
142    }
143
144    #[test]
145    fn single_boolean_variable() {
146        let output = generate(
147            r#"1: Feature Flags = {
148    1: enabled Boolean = true
149}"#,
150        );
151        insta::assert_snapshot!(output);
152    }
153
154    #[test]
155    fn single_integer_variable() {
156        let output = generate(
157            r#"1: Feature Config = {
158    1: max_items Integer = 50
159}"#,
160        );
161        insta::assert_snapshot!(output);
162    }
163
164    #[test]
165    fn single_float_variable() {
166        let output = generate(
167            r#"1: Feature Config = {
168    1: ratio Float = 3.14
169}"#,
170        );
171        insta::assert_snapshot!(output);
172    }
173
174    #[test]
175    fn single_string_variable() {
176        let output = generate(
177            r#"1: Feature Config = {
178    1: title String = "Hello"
179}"#,
180        );
181        insta::assert_snapshot!(output);
182    }
183
184    #[test]
185    fn multiple_variables() {
186        let output = generate(
187            r#"1: Feature Checkout = {
188    1: enabled Boolean = true
189    2: max_items Integer = 50
190    3: header_text String = "Complete your purchase"
191}"#,
192        );
193        insta::assert_snapshot!(output);
194    }
195
196    #[test]
197    fn multiple_features() {
198        let output = generate(
199            r#"1: Feature Checkout = {
200    1: enabled Boolean = true
201}
202
2032: Feature Search = {
204    1: query String = "default"
205}"#,
206        );
207        insta::assert_snapshot!(output);
208    }
209
210    #[test]
211    fn string_with_special_characters() {
212        let output = generate(
213            r#"1: Feature Config = {
214    1: message String = "He said \"hello\"\nNew line"
215}"#,
216        );
217        insta::assert_snapshot!(output);
218    }
219
220    #[test]
221    fn full_example() {
222        let output = generate(
223            r#"1: Feature Checkout = {
224    1: enabled Boolean = true
225    2: max_items Integer = 50
226    3: header_text String = "Complete your purchase"
227    4: discount_rate Float = 0.15
228}
229
2302: Feature Search = {
231    1: enabled Boolean = false
232    2: max_results Integer = 10
233    3: placeholder String = "Search..."
234    4: boost_factor Float = 1.5
235}"#,
236        );
237        insta::assert_snapshot!(output);
238    }
239
240    #[test]
241    fn feature_with_struct_type() {
242        let output = generate(
243            r#"1: Struct Theme = {
244    1: dark_mode Boolean = false
245    2: font_size Integer = 14
246}
247
2481: Feature Dashboard = {
249    1: enabled Boolean = true
250    2: theme Theme = Theme {}
251}"#,
252        );
253        insta::assert_snapshot!(output);
254    }
255
256    #[test]
257    fn feature_with_struct_overrides() {
258        let output = generate(
259            r#"1: Struct Config = {
260    1: retries Integer = 3
261    2: verbose Boolean = false
262}
263
2641: Feature App = {
265    1: config Config = Config { retries = 5 }
266}"#,
267        );
268        insta::assert_snapshot!(output);
269    }
270}