Skip to main content

rusteal_codegen/
lib.rs

1pub mod config;
2pub mod context;
3pub mod cpp_gen;
4pub mod defaults;
5pub mod filter;
6pub mod naming;
7pub mod rust_gen;
8pub mod schema;
9pub mod type_map;
10
11use std::path::Path;
12
13use crate::config::{
14    CodegenConfig, LibraryKind, LibraryOutput, PluginConfig, PluginLayout, ProjectConfig,
15    ProjectLayout,
16};
17use crate::schema::{ClassesFile, EnumsFile, StructsFile};
18
19pub(crate) fn write_if_changed(path: &Path, contents: &str) -> std::io::Result<()> {
20    if std::fs::read(path).is_ok_and(|old| old == contents.as_bytes()) {
21        return Ok(());
22    }
23
24    std::fs::write(path, contents)
25}
26
27pub fn run_generate(project_root: &Path) {
28    let config = ProjectConfig::load(project_root).unwrap_or_else(|e| {
29        eprintln!("rusteal-codegen: {e}");
30        std::process::exit(1);
31    });
32
33    let layout = ProjectLayout::new(project_root);
34    generate_library(&layout.uht_json(), &config.codegen, &layout.game_output());
35}
36
37pub fn run_generate_plugin(project_root: &Path, plugin: &PluginLayout) {
38    let config = PluginConfig::load(&plugin.dir).unwrap_or_else(|e| {
39        eprintln!("rusteal-codegen: {e}");
40        std::process::exit(1);
41    });
42
43    let layout = ProjectLayout::new(project_root);
44    eprintln!("rusteal-codegen: plugin {}", plugin.name);
45
46    generate_library(
47        &layout.uht_json(),
48        &config.codegen,
49        &plugin.output(config.plugin.kind),
50    );
51}
52
53pub fn generate_library(uht_input: &Path, codegen: &CodegenConfig, output: &LibraryOutput) {
54    let codegen = &codegen.for_library(output.kind);
55    let classes_path = uht_input.join("rusteal_classes.json");
56    let structs_path = uht_input.join("rusteal_structs.json");
57    let enums_path = uht_input.join("rusteal_enums.json");
58
59    let rust_out = output.bindings_crate.clone();
60    let rust_src = rust_out.join("src");
61    let cpp_out = output.cpp_generated.clone();
62
63    eprintln!("rusteal-codegen: loading JSON...");
64
65    let classes_json: ClassesFile = {
66        let data = std::fs::read_to_string(&classes_path)
67            .unwrap_or_else(|e| panic!("Failed to read {}: {e}", classes_path.display()));
68        serde_json::from_str(&data)
69            .unwrap_or_else(|e| panic!("Failed to parse {}: {e}", classes_path.display()))
70    };
71
72    let structs_json: StructsFile = {
73        let data = std::fs::read_to_string(&structs_path)
74            .unwrap_or_else(|e| panic!("Failed to read {}: {e}", structs_path.display()));
75        serde_json::from_str(&data)
76            .unwrap_or_else(|e| panic!("Failed to parse {}: {e}", structs_path.display()))
77    };
78
79    let enums_json: EnumsFile = {
80        let data = std::fs::read_to_string(&enums_path)
81            .unwrap_or_else(|e| panic!("Failed to read {}: {e}", enums_path.display()));
82        serde_json::from_str(&data)
83            .unwrap_or_else(|e| panic!("Failed to parse {}: {e}", enums_path.display()))
84    };
85
86    eprintln!(
87        "  Loaded {} classes, {} structs, {} enums",
88        classes_json.classes.len(),
89        structs_json.structs.len(),
90        enums_json.enums.len()
91    );
92
93    let mut ctx = context::CodegenContext::new(
94        classes_json.classes,
95        structs_json.structs,
96        enums_json.enums,
97        codegen,
98    );
99
100    ctx.cpp_prefix = output.cpp_prefix.clone();
101
102    eprintln!(
103        "  Enabled modules: {:?}",
104        ctx.enabled_modules.iter().collect::<Vec<_>>()
105    );
106
107    for (module, classes) in &ctx.module_classes {
108        eprintln!("    {}: {} classes", module, classes.len());
109    }
110
111    for (module, structs) in &ctx.module_structs {
112        eprintln!("    {}: {} structs", module, structs.len());
113    }
114
115    for (module, enums) in &ctx.module_enums {
116        eprintln!("    {}: {} enums", module, enums.len());
117    }
118
119    eprintln!("rusteal-codegen: filtering...");
120    let blocklist = with_unlisted_classes(&ctx, codegen);
121    filter::apply_filters(&mut ctx, &blocklist, output.kind == LibraryKind::Editor);
122
123    eprintln!("rusteal-codegen: building function table...");
124    build_func_table(&mut ctx);
125    eprintln!("  {} functions in func_table", ctx.func_table.len());
126
127    eprintln!("rusteal-codegen: generating Rust code...");
128    rust_gen::generate(&ctx, &rust_src);
129    rust_gen::manual::write_manual_module(&rust_src);
130    rust_gen::prelude::write_prelude(&rust_src, &ctx);
131    rust_gen::cargo_toml::write_crate_files(&rust_out, &ctx, codegen);
132
133    eprintln!("rusteal-codegen: generating C++ code...");
134    cpp_gen::generate(&ctx, &cpp_out);
135
136    generate_module_deps(codegen, &output.host_module, &cpp_out);
137    update_plugin_dependencies(codegen, &output.plugin_descriptor);
138
139    eprintln!("rusteal-codegen: verifying output...");
140    verify_output(&ctx, &rust_src, &cpp_out);
141
142    eprintln!("rusteal-codegen: done!");
143}
144
145fn with_unlisted_classes(
146    ctx: &context::CodegenContext,
147    codegen: &CodegenConfig,
148) -> config::Blocklist {
149    use std::collections::HashSet;
150
151    let mut blocklist = codegen.blocklist.clone();
152
153    for (package, mapping) in &codegen.modules {
154        let Some(listed) = &mapping.classes else {
155            continue;
156        };
157
158        let mut wanted: HashSet<&str> = HashSet::new();
159
160        for name in listed {
161            let mut current = Some(name.as_str());
162
163            while let Some(class) = current.and_then(|n| ctx.classes.get(n)) {
164                if &class.package != package || !wanted.insert(class.name.as_str()) {
165                    break;
166                }
167
168                current = class.super_class.as_deref();
169            }
170        }
171
172        for class in ctx
173            .module_classes
174            .get(&mapping.module)
175            .into_iter()
176            .flatten()
177        {
178            if !wanted.contains(class.name.as_str()) {
179                blocklist.classes.push(class.name.clone());
180            }
181        }
182    }
183
184    blocklist
185}
186
187fn generate_module_deps(config: &CodegenConfig, host_module: &str, cpp_out: &Path) {
188    use std::collections::BTreeSet;
189
190    let enabled_features: std::collections::HashSet<&str> =
191        config.features.iter().map(|s| s.as_str()).collect();
192
193    let mut ue_modules: BTreeSet<&str> = BTreeSet::new();
194    ue_modules.insert("Core");
195
196    for (pkg, mapping) in &config.modules {
197        if enabled_features.contains(mapping.feature.as_str()) {
198            ue_modules.insert(pkg.as_str());
199        }
200    }
201
202    ue_modules.remove(host_module);
203
204    let content = ue_modules.iter().copied().collect::<Vec<_>>().join("\n");
205
206    let path = cpp_out.join("module_deps.txt");
207
208    write_if_changed(&path, &content)
209        .unwrap_or_else(|e| panic!("Failed to write {}: {e}", path.display()));
210
211    eprintln!(
212        "  module_deps.txt: {:?}",
213        ue_modules.iter().collect::<Vec<_>>()
214    );
215}
216
217fn update_plugin_dependencies(config: &CodegenConfig, descriptor: &Path) {
218    let Ok(text) = std::fs::read_to_string(descriptor) else {
219        return;
220    };
221
222    let mut json: serde_json::Value = serde_json::from_str(&text)
223        .unwrap_or_else(|e| panic!("Failed to parse {}: {e}", descriptor.display()));
224
225    let enabled: std::collections::HashSet<&str> =
226        config.features.iter().map(|s| s.as_str()).collect();
227
228    let mut wanted: Vec<&str> = config
229        .modules
230        .values()
231        .filter(|m| enabled.contains(m.feature.as_str()))
232        .filter_map(|m| m.plugin.as_deref())
233        .collect();
234
235    wanted.sort();
236    wanted.dedup();
237
238    let plugins = json
239        .as_object_mut()
240        .expect("a plugin descriptor is a JSON object")
241        .entry("Plugins")
242        .or_insert_with(|| serde_json::Value::Array(Vec::new()))
243        .as_array_mut()
244        .expect("a plugin descriptor's Plugins is an array");
245
246    let mut added = Vec::new();
247
248    for name in wanted {
249        if plugins.iter().any(|p| p["Name"] == name) {
250            continue;
251        }
252
253        plugins.push(serde_json::json!({ "Name": name, "Enabled": true }));
254        added.push(name);
255    }
256
257    if added.is_empty() {
258        return;
259    }
260
261    let mut out = serde_json::to_string_pretty(&json).expect("serializable descriptor");
262    out.push('\n');
263
264    std::fs::write(descriptor, out)
265        .unwrap_or_else(|e| panic!("Failed to write {}: {e}", descriptor.display()));
266
267    let name = descriptor.file_name().unwrap_or_default().to_string_lossy();
268    eprintln!("  {name}: now depends on {added:?}");
269}
270
271fn verify_output(ctx: &context::CodegenContext, rust_out: &Path, cpp_out: &Path) {
272    let mut errors: Vec<String> = Vec::new();
273
274    for (i, entry) in ctx.func_table.iter().enumerate() {
275        if entry.func_id != i as u32 {
276            errors.push(format!(
277                "FuncId gap: expected {} for module '{}' {}.{}, got {} (total functions: {})",
278                i,
279                entry.module_name,
280                entry.class_name,
281                entry.func_name,
282                entry.func_id,
283                ctx.func_table.len()
284            ));
285
286            break;
287        }
288    }
289
290    let rust_required = ["lib.rs", "func_ids.rs"];
291
292    for name in &rust_required {
293        let path = rust_out.join(name);
294
295        match std::fs::metadata(&path) {
296            Ok(m) if m.len() == 0 => errors.push(format!("Rust output empty: {}", path.display())),
297            Err(_) => errors.push(format!("Rust output missing: {}", path.display())),
298            _ => {}
299        }
300    }
301
302    for module_name in ctx.enabled_modules.iter() {
303        let mod_path = rust_out.join(module_name).join("mod.rs");
304
305        if !mod_path.exists() {
306            errors.push(format!("Module mod.rs missing: {}", mod_path.display()));
307        }
308    }
309
310    let cpp_required = ["RustealFuncIds.h", "RustealFillFuncTable.cpp"];
311
312    for name in &cpp_required {
313        let path = cpp_out.join(name);
314
315        match std::fs::metadata(&path) {
316            Ok(m) if m.len() == 0 => errors.push(format!("C++ output empty: {}", path.display())),
317            Err(_) => errors.push(format!("C++ output missing: {}", path.display())),
318            _ => {}
319        }
320    }
321
322    let func_count = ctx.func_table.len();
323    let module_count = ctx.enabled_modules.len();
324    let class_count: usize = ctx.module_classes.values().map(|v| v.len()).sum();
325    let struct_count: usize = ctx.module_structs.values().map(|v| v.len()).sum();
326    let enum_count: usize = ctx.module_enums.values().map(|v| v.len()).sum();
327
328    if errors.is_empty() {
329        eprintln!(
330            "  OK: {module_count} modules, {class_count} classes, {struct_count} structs, {enum_count} enums, {func_count} functions"
331        );
332    } else {
333        eprintln!("  Verification FAILED:");
334
335        for e in &errors {
336            eprintln!("    - {e}");
337        }
338
339        std::process::exit(1);
340    }
341}
342
343fn build_func_table(ctx: &mut context::CodegenContext) {
344    let mut entries = Vec::new();
345
346    for (module_name, classes) in &ctx.module_classes {
347        for class in classes {
348            if class.super_class.as_deref() == Some("Interface") {
349                continue;
350            }
351
352            for func in &class.funcs {
353                let all_supported = func.params.iter().all(|p| {
354                    type_map::map_property_type(
355                        &p.prop_type,
356                        p.class_name.as_deref(),
357                        p.struct_name.as_deref(),
358                        p.enum_name.as_deref(),
359                        p.enum_underlying_type.as_deref(),
360                        p.meta_class_name.as_deref(),
361                        p.interface_name.as_deref(),
362                    )
363                    .supported
364                });
365
366                if !all_supported {
367                    continue;
368                }
369
370                entries.push(context::FuncEntry {
371                    func_id: 0,
372                    module_name: module_name.clone(),
373                    class_name: class.name.clone(),
374                    func_name: func.name.clone(),
375                    rust_func_name: naming::to_snake_case(&func.name),
376                    func: func.clone(),
377                    cpp_class_name: class.cpp_name.clone(),
378                    header: class.header.clone(),
379                });
380            }
381        }
382    }
383
384    entries.sort_by(|a, b| {
385        a.module_name
386            .cmp(&b.module_name)
387            .then_with(|| a.class_name.cmp(&b.class_name))
388            .then_with(|| a.func_name.cmp(&b.func_name))
389    });
390
391    for (i, entry) in entries.iter_mut().enumerate() {
392        entry.func_id = i as u32;
393    }
394
395    ctx.func_table = entries;
396}
397
398#[cfg(test)]
399mod tests {
400    use super::*;
401
402    #[test]
403    fn listed_classes_keep_their_ancestors_only() {
404        let class = |name: &str, package: &str, parent: &str| -> schema::ClassInfo {
405            serde_json::from_value(serde_json::json!({
406                "name": name, "cpp_name": format!("U{name}"), "package": package,
407                "header": "", "class_flags": 0, "super": parent,
408            }))
409            .unwrap()
410        };
411
412        let codegen: CodegenConfig = toml::from_str(
413            r#"
414            features = ["core", "editor"]
415            [modules]
416            CoreUObject = { module = "core_ue", feature = "core" }
417            UnrealEd = { module = "unreal_ed", feature = "editor", classes = ["EditorActorSubsystem"] }
418            "#,
419        )
420        .unwrap();
421
422        let ctx = context::CodegenContext::new(
423            vec![
424                class("Object", "CoreUObject", ""),
425                class("EditorSubsystemBase", "UnrealEd", "Object"),
426                class("EditorActorSubsystem", "UnrealEd", "EditorSubsystemBase"),
427                class("Factory", "UnrealEd", "Object"),
428            ],
429            vec![],
430            vec![],
431            &codegen,
432        );
433
434        let blocked = with_unlisted_classes(&ctx, &codegen).classes;
435        assert_eq!(blocked, ["Factory"]);
436    }
437
438    #[test]
439    fn unchanged_output_keeps_its_timestamp() {
440        let dir = std::env::temp_dir().join(format!("rusteal-write-{}", std::process::id()));
441        std::fs::create_dir_all(&dir).unwrap();
442        let path = dir.join("out.rs");
443        write_if_changed(&path, "a").unwrap();
444        let old = std::time::SystemTime::now() - std::time::Duration::from_secs(3600);
445
446        std::fs::File::options()
447            .write(true)
448            .open(&path)
449            .unwrap()
450            .set_modified(old)
451            .unwrap();
452
453        write_if_changed(&path, "a").unwrap();
454        assert_eq!(std::fs::metadata(&path).unwrap().modified().unwrap(), old);
455        write_if_changed(&path, "b").unwrap();
456        assert_ne!(std::fs::metadata(&path).unwrap().modified().unwrap(), old);
457        assert_eq!(std::fs::read_to_string(&path).unwrap(), "b");
458        std::fs::remove_dir_all(&dir).unwrap();
459    }
460
461    #[test]
462    fn plugin_dependencies_follow_enabled_modules() {
463        let config: crate::config::CodegenConfig = toml::from_str(
464            r#"
465            features = ["engine", "state-tree"]
466            [modules]
467            Engine = { module = "engine", feature = "engine" }
468            StateTreeModule = { module = "state_tree", feature = "state-tree", plugin = "StateTree" }
469            GameplayStateTreeModule = { module = "gameplay_state_tree", feature = "state-tree", plugin = "GameplayStateTree" }
470            Niagara = { module = "niagara", feature = "niagara", plugin = "Niagara" }
471            "#,
472        )
473        .unwrap();
474
475        let dir = std::env::temp_dir().join(format!("rusteal-uplugin-{}", std::process::id()));
476        std::fs::create_dir_all(&dir).unwrap();
477        let descriptor = dir.join("Rusteal.uplugin");
478
479        std::fs::write(
480            &descriptor,
481            r#"{ "VersionName": "0.0.0", "Plugins": [ { "Name": "EnhancedInput", "Enabled": true } ] }"#,
482        )
483        .unwrap();
484
485        update_plugin_dependencies(&config, &descriptor);
486        update_plugin_dependencies(&config, &descriptor);
487
488        let json: serde_json::Value =
489            serde_json::from_str(&std::fs::read_to_string(&descriptor).unwrap()).unwrap();
490
491        let names: Vec<&str> = json["Plugins"]
492            .as_array()
493            .unwrap()
494            .iter()
495            .map(|p| p["Name"].as_str().unwrap())
496            .collect();
497
498        assert_eq!(names, ["EnhancedInput", "GameplayStateTree", "StateTree"]);
499        assert_eq!(json["VersionName"], "0.0.0");
500        std::fs::remove_dir_all(&dir).unwrap();
501    }
502}