use crate::template::{
self, FeatureContext, FileContext, StructContext, StructFieldContext, VariableContext,
};
use variable_core::ast::{Value, VarFile, VarType};
const TEMPLATE: &str = include_str!("templates/typescript.ts.j2");
fn to_camel_case(s: &str) -> String {
let mut result = String::new();
let mut capitalize_next = false;
for (i, c) in s.chars().enumerate() {
if c == '_' {
capitalize_next = true;
} else if capitalize_next {
result.push(c.to_uppercase().next().unwrap());
capitalize_next = false;
} else if i == 0 {
result.push(c.to_lowercase().next().unwrap());
} else {
result.push(c);
}
}
result
}
fn ts_type(var_type: &VarType) -> String {
match var_type {
VarType::Boolean => "boolean".to_string(),
VarType::Integer => "number".to_string(),
VarType::Float => "number".to_string(),
VarType::String => "string".to_string(),
VarType::Struct(name) => name.clone(),
}
}
fn ts_value(value: &Value, var_file: &VarFile) -> String {
match value {
Value::Boolean(b) => b.to_string(),
Value::Integer(n) => format!("{}", n),
Value::Float(n) => {
if *n == (*n as i64) as f64 {
format!("{}", *n as i64)
} else {
format!("{}", n)
}
}
Value::String(s) => {
let escaped = s
.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('\n', "\\n")
.replace('\r', "\\r")
.replace('\t', "\\t");
format!("\"{}\"", escaped)
}
Value::Struct {
struct_name,
fields,
} => {
let struct_def = var_file.structs.iter().find(|s| &s.name == struct_name);
let mut all_fields = Vec::new();
if let Some(def) = struct_def {
for field in &def.fields {
let val = fields.get(&field.name).unwrap_or(&field.default);
all_fields.push(format!(" {}: {}", field.name, ts_value(val, var_file)));
}
} else {
for (name, val) in fields {
all_fields.push(format!(" {}: {}", name, ts_value(val, var_file)));
}
}
if all_fields.is_empty() {
"{}".to_string()
} else {
format!("{{\n{},\n }}", all_fields.join(",\n"))
}
}
}
}
fn build_context(var_file: &VarFile) -> FileContext {
let structs = var_file
.structs
.iter()
.map(|struct_def| StructContext {
name: struct_def.name.clone(),
fields: struct_def
.fields
.iter()
.map(|field| StructFieldContext {
name: field.name.clone(),
type_name: ts_type(&field.field_type),
})
.collect(),
})
.collect();
let features = var_file
.features
.iter()
.map(|feature| {
let camel_name = to_camel_case(&feature.name);
FeatureContext {
name: feature.name.clone(),
id: feature.id,
interface_name: format!("{}Variables", feature.name),
defaults_name: format!("{}Defaults", camel_name),
variable_ids_name: format!("{}VariableIds", camel_name),
fn_name: format!("get{}Variables", feature.name),
variables: feature
.variables
.iter()
.map(|var| VariableContext {
name: var.name.clone(),
type_name: ts_type(&var.var_type),
default_value: ts_value(&var.default, var_file),
id: var.id,
})
.collect(),
}
})
.collect();
FileContext { structs, features }
}
pub fn generate_typescript(var_file: &VarFile) -> String {
let context = build_context(var_file);
template::render(TEMPLATE, &context)
}
#[cfg(test)]
mod tests {
use super::*;
use variable_core::parse_and_validate;
fn generate(input: &str) -> String {
let var_file = parse_and_validate(input).expect("parse failed");
generate_typescript(&var_file)
}
#[test]
fn single_boolean_variable() {
let output = generate(
r#"1: Feature Flags = {
1: enabled Boolean = true
}"#,
);
insta::assert_snapshot!(output);
}
#[test]
fn single_integer_variable() {
let output = generate(
r#"1: Feature Config = {
1: max_items Integer = 50
}"#,
);
insta::assert_snapshot!(output);
}
#[test]
fn single_float_variable() {
let output = generate(
r#"1: Feature Config = {
1: ratio Float = 3.14
}"#,
);
insta::assert_snapshot!(output);
}
#[test]
fn single_string_variable() {
let output = generate(
r#"1: Feature Config = {
1: title String = "Hello"
}"#,
);
insta::assert_snapshot!(output);
}
#[test]
fn multiple_variables() {
let output = generate(
r#"1: Feature Checkout = {
1: enabled Boolean = true
2: max_items Integer = 50
3: header_text String = "Complete your purchase"
}"#,
);
insta::assert_snapshot!(output);
}
#[test]
fn multiple_features() {
let output = generate(
r#"1: Feature Checkout = {
1: enabled Boolean = true
}
2: Feature Search = {
1: query String = "default"
}"#,
);
insta::assert_snapshot!(output);
}
#[test]
fn string_with_special_characters() {
let output = generate(
r#"1: Feature Config = {
1: message String = "He said \"hello\"\nNew line"
}"#,
);
insta::assert_snapshot!(output);
}
#[test]
fn full_example() {
let output = generate(
r#"1: Feature Checkout = {
1: enabled Boolean = true
2: max_items Integer = 50
3: header_text String = "Complete your purchase"
4: discount_rate Float = 0.15
}
2: Feature Search = {
1: enabled Boolean = false
2: max_results Integer = 10
3: placeholder String = "Search..."
4: boost_factor Float = 1.5
}"#,
);
insta::assert_snapshot!(output);
}
#[test]
fn feature_with_struct_type() {
let output = generate(
r#"1: Struct Theme = {
1: dark_mode Boolean = false
2: font_size Integer = 14
}
1: Feature Dashboard = {
1: enabled Boolean = true
2: theme Theme = Theme {}
}"#,
);
insta::assert_snapshot!(output);
}
#[test]
fn feature_with_struct_overrides() {
let output = generate(
r#"1: Struct Config = {
1: retries Integer = 3
2: verbose Boolean = false
}
1: Feature App = {
1: config Config = Config { retries = 5 }
}"#,
);
insta::assert_snapshot!(output);
}
}