forge-codegen 0.10.2

TypeScript code generator for the Forge framework
Documentation
//! Svelte 5 runes-native reactive bindings generator.
//!
//! Generates `reactive.svelte.ts` — thin wrappers around the Store$
//! subscription factories from `api.ts` (via `toReactive()`) and
//! mutation RPC calls (via `toReactiveMutation()`).

use forge_core::util::to_camel_case;

use crate::Error;
use crate::binding::BindingSet;
use crate::emit::{self, Position};

use super::api::ts_args_type;

pub fn generate(bindings: &BindingSet) -> Result<String, Error> {
    if bindings.queries.is_empty() && bindings.mutations.is_empty() {
        return Ok(String::new());
    }

    let mut output = String::from("// @generated by FORGE - DO NOT EDIT\n\n");

    let mut api_imports: Vec<String> = Vec::new();
    for b in &bindings.queries {
        api_imports.push(format!("{}Store$", to_camel_case(&b.name)));
    }
    for b in &bindings.mutations {
        api_imports.push(to_camel_case(&b.name));
    }
    output.push_str(&format!(
        "import {{ {} }} from \"./api\";\n",
        api_imports.join(", ")
    ));

    let mut runes_imports: Vec<&str> = Vec::new();
    if !bindings.queries.is_empty() {
        runes_imports.push("toReactive");
        runes_imports.push("type ReactiveQuery");
    }
    if !bindings.mutations.is_empty() {
        runes_imports.push("toReactiveMutation");
        runes_imports.push("type ReactiveMutation");
    }
    output.push_str(&format!(
        "import {{ {} }} from \"./runes.svelte\";\n",
        runes_imports.join(", ")
    ));

    let mut type_imports = Vec::new();
    for b in bindings.queries.iter().chain(bindings.mutations.iter()) {
        emit::collect_type_imports(&b.return_type, &mut type_imports);
        for arg in &b.args {
            emit::collect_type_imports(&arg.rust_type, &mut type_imports);
        }
    }
    type_imports.sort();
    type_imports.dedup();
    if !type_imports.is_empty() {
        output.push_str(&format!(
            "import type {{ {} }} from \"./types\";\n",
            type_imports.join(", ")
        ));
    }

    for b in &bindings.queries {
        let ts_name = to_camel_case(&b.name);
        let result_type = emit::ts_type(&b.return_type, Position::Return);

        if b.has_args() {
            let args_type = ts_args_type(b);
            output.push_str(&format!(
                "export const {}$ = (args: {}): ReactiveQuery<{}> =>\n  toReactive({}Store$(args));\n",
                ts_name, args_type, result_type, ts_name
            ));
        } else {
            output.push_str(&format!(
                "export const {}$ = (): ReactiveQuery<{}> =>\n  toReactive({}Store$());\n",
                ts_name, result_type, ts_name
            ));
        }
    }

    for b in &bindings.mutations {
        let ts_name = to_camel_case(&b.name);
        let result_type = emit::ts_type(&b.return_type, Position::Return);
        let args_type = if b.has_args() {
            ts_args_type(b)
        } else {
            "null".to_string()
        };

        output.push_str(&format!(
            "export const {}$ = (): ReactiveMutation<{}, {}> =>\n  toReactiveMutation({});\n",
            ts_name, args_type, result_type, ts_name
        ));
    }

    Ok(output)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::binding::BindingSet;
    use forge_core::schema::{FunctionDef, RustType, SchemaRegistry};

    #[test]
    fn test_generate_reactive_empty() {
        let registry = SchemaRegistry::new();
        let bindings = BindingSet::from_registry(&registry);
        let content = generate(&bindings).expect("empty reactive bindings should generate");
        assert!(content.is_empty());
    }

    #[test]
    fn test_generate_reactive_with_query() {
        let registry = SchemaRegistry::new();

        let func = FunctionDef::query(
            "list_users",
            RustType::Vec(Box::new(RustType::Custom("User".into()))),
        );
        registry.register_function(func);

        let bindings = BindingSet::from_registry(&registry);
        let content = generate(&bindings).expect("reactive query bindings should generate");
        assert!(content.contains("listUsers$"));
        assert!(content.contains("ReactiveQuery<User[]>"));
        assert!(content.contains("toReactive(listUsersStore$())"));
    }

    #[test]
    fn test_generate_reactive_mutation() {
        let registry = SchemaRegistry::new();

        let mut func = FunctionDef::mutation("create_user", RustType::Custom("User".into()));
        func.args.push(forge_core::schema::FunctionArg::new(
            "name",
            RustType::String,
        ));
        registry.register_function(func);

        let bindings = BindingSet::from_registry(&registry);
        let content = generate(&bindings).expect("reactive mutation bindings should generate");
        assert!(content.contains("createUser$"));
        assert!(content.contains("ReactiveMutation"));
        assert!(content.contains("toReactiveMutation(createUser)"));
    }

    #[test]
    fn test_generate_reactive_skips_when_only_crons() {
        let registry = SchemaRegistry::new();
        registry.register_function(FunctionDef::new(
            "daily_cleanup",
            forge_core::schema::FunctionKind::Cron,
            RustType::Custom("()".into()),
        ));

        let bindings = BindingSet::from_registry(&registry);
        let content = generate(&bindings).expect("no client-callable bindings should generate");
        assert!(content.is_empty());
    }
}