variable-codegen 0.1.4

TypeScript code generation for the Variable feature flag DSL
Documentation
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,
        } => {
            // Resolve all fields (merge struct defaults with overrides)
            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 {
                // No definition found; just render the provided fields
                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);
    }
}