rusteal-codegen 0.8.0

Code generator: UHT JSON to Rust bindings and C++ wrappers for rusteal
Documentation
use serde::Deserialize;
use std::collections::HashMap;
use std::path::{Path, PathBuf};

pub const FILE_NAME: &str = "rusteal.toml";

#[derive(Deserialize)]
pub struct ProjectConfig {
    pub project: ProjectSection,
    pub codegen: CodegenConfig,
}

#[derive(Deserialize)]
pub struct ProjectSection {
    #[serde(rename = "crate")]
    pub crate_name: String,
    #[serde(default)]
    pub features: Vec<String>,
}

#[derive(Deserialize)]
pub struct PluginConfig {
    pub plugin: PluginSection,
    pub codegen: CodegenConfig,
}

#[derive(Deserialize)]
pub struct PluginSection {
    #[serde(rename = "crate")]
    pub crate_name: String,
    #[serde(default)]
    pub features: Vec<String>,
    #[serde(default)]
    pub kind: LibraryKind,
}

#[derive(Deserialize, Default, Clone, Copy, Debug, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum LibraryKind {
    #[default]
    Runtime,
    Editor,
}

impl PluginConfig {
    pub fn load(plugin_dir: &Path) -> Result<Self, String> {
        let path = plugin_dir.join(FILE_NAME);

        let text = std::fs::read_to_string(&path)
            .map_err(|e| format!("cannot read {}: {e}", path.display()))?;

        toml::from_str(&text).map_err(|e| format!("cannot parse {}: {e}", path.display()))
    }
}

#[derive(Deserialize, Clone)]
pub struct CodegenConfig {
    pub features: Vec<String>,
    pub modules: HashMap<String, ModuleMapping>,
    #[serde(default)]
    pub blocklist: Blocklist,
}

#[derive(Deserialize, Clone)]
pub struct ModuleMapping {
    pub module: String,
    pub feature: String,
    #[serde(default)]
    pub plugin: Option<String>,
    #[serde(default)]
    pub classes: Option<Vec<String>>,
}

#[derive(Deserialize, Default, Clone)]
pub struct Blocklist {
    #[serde(default)]
    pub classes: Vec<String>,
    #[serde(default)]
    pub structs: Vec<String>,
    #[serde(default)]
    pub functions: Vec<String>,
}

const RUSTEAL_INTERNAL_CLASSES: &[&str] = &[
    "RustealReifiedClass",
    "RustealReifiedFunction",
    "RustealDelegateProxy",
];

impl CodegenConfig {
    pub fn for_library(&self, kind: LibraryKind) -> CodegenConfig {
        let mut config = self.clone();

        let core = config
            .features
            .first()
            .cloned()
            .unwrap_or_else(|| "core".to_string());

        let mut add = |package: &str, module: &str| {
            config
                .modules
                .entry(package.to_string())
                .or_insert_with(|| ModuleMapping {
                    module: module.to_string(),
                    feature: core.clone(),
                    plugin: None,
                    classes: None,
                });
        };

        add("Rusteal", "rusteal");

        if kind == LibraryKind::Editor {
            add("RustealEditor", "rusteal_editor");
        }

        for class in RUSTEAL_INTERNAL_CLASSES {
            if !config.blocklist.classes.iter().any(|c| c == class) {
                config.blocklist.classes.push(class.to_string());
            }
        }

        config
    }
}

impl Blocklist {
    pub fn function_tuples(&self) -> Vec<(String, String)> {
        self.functions
            .iter()
            .filter_map(|entry| {
                let (class, func) = entry.split_once('.')?;

                Some((class.to_string(), func.to_string()))
            })
            .collect()
    }
}

impl ProjectConfig {
    pub fn load(root: &Path) -> Result<Self, String> {
        let path = root.join(FILE_NAME);

        let text = std::fs::read_to_string(&path)
            .map_err(|e| format!("cannot read {}: {e}", path.display()))?;

        toml::from_str(&text).map_err(|e| format!("cannot parse {}: {e}", path.display()))
    }
}

pub struct ProjectLayout {
    pub root: PathBuf,
}

impl ProjectLayout {
    pub fn new(root: &Path) -> Self {
        Self {
            root: root.to_path_buf(),
        }
    }

    pub fn uproject(&self) -> Option<PathBuf> {
        find_uproject(&self.root)
    }

    pub fn rust_workspace(&self) -> PathBuf {
        self.root.join("Rust")
    }

    pub fn bindings_crate(&self) -> PathBuf {
        self.rust_workspace().join("bindings")
    }

    pub fn cpp_generated(&self) -> PathBuf {
        self.root.join("Plugins/Rusteal/Source/Rusteal/Generated")
    }

    pub fn plugin_descriptor(&self) -> PathBuf {
        self.root.join("Plugins/Rusteal/Rusteal.uplugin")
    }

    pub fn uht_json(&self) -> PathBuf {
        self.root.join("Intermediate/Rusteal/uht")
    }

    pub fn config_file(&self) -> PathBuf {
        self.root.join(FILE_NAME)
    }
}

pub struct LibraryOutput {
    pub bindings_crate: PathBuf,
    pub cpp_generated: PathBuf,
    pub plugin_descriptor: PathBuf,
    pub host_module: String,
    pub cpp_prefix: String,
    pub kind: LibraryKind,
}

impl ProjectLayout {
    pub fn game_output(&self) -> LibraryOutput {
        LibraryOutput {
            bindings_crate: self.bindings_crate(),
            cpp_generated: self.cpp_generated(),
            plugin_descriptor: self.plugin_descriptor(),
            host_module: "Rusteal".to_string(),
            cpp_prefix: String::new(),
            kind: LibraryKind::Runtime,
        }
    }
}

pub struct PluginLayout {
    pub dir: PathBuf,
    pub name: String,
}

impl PluginLayout {
    pub fn new(dir: &Path) -> Self {
        let name = dir
            .file_name()
            .map(|n| n.to_string_lossy().into_owned())
            .unwrap_or_default();

        Self {
            dir: dir.to_path_buf(),
            name,
        }
    }

    pub fn descriptor(&self) -> PathBuf {
        self.dir.join(format!("{}.uplugin", self.name))
    }

    pub fn config_file(&self) -> PathBuf {
        self.dir.join(FILE_NAME)
    }

    pub fn rust_workspace(&self) -> PathBuf {
        self.dir.join("Rust")
    }

    pub fn module_dir(&self) -> PathBuf {
        self.dir.join("Source").join(&self.name)
    }

    pub fn binaries(&self, platform: &str) -> PathBuf {
        self.dir.join("Binaries").join(platform)
    }

    pub fn library_stem(&self) -> String {
        format!("rusteal_{}", self.name)
    }

    pub fn output(&self, kind: LibraryKind) -> LibraryOutput {
        LibraryOutput {
            bindings_crate: self.rust_workspace().join("bindings"),
            cpp_generated: self.module_dir().join("Generated"),
            plugin_descriptor: self.descriptor(),
            host_module: self.name.clone(),
            cpp_prefix: format!("{}_", self.name),
            kind,
        }
    }
}

pub fn find_plugins(project_root: &Path) -> Vec<PluginLayout> {
    let Ok(entries) = std::fs::read_dir(project_root.join("Plugins")) else {
        return Vec::new();
    };

    let mut plugins: Vec<PluginLayout> = entries
        .flatten()
        .map(|entry| entry.path())
        .filter(|dir| dir.join(FILE_NAME).is_file())
        .map(|dir| PluginLayout::new(&dir))
        .collect();

    plugins.sort_by(|a, b| a.name.cmp(&b.name));
    plugins
}

pub fn find_uproject(dir: &Path) -> Option<PathBuf> {
    std::fs::read_dir(dir)
        .ok()?
        .flatten()
        .map(|entry| entry.path())
        .find(|path| path.extension().is_some_and(|ext| ext == "uproject"))
}

pub fn find_project_root(start: &Path) -> Option<PathBuf> {
    let start = start.canonicalize().ok()?;

    start
        .ancestors()
        .find(|dir| find_uproject(dir).is_some())
        .map(Path::to_path_buf)
}

#[cfg(test)]
mod tests {
    use super::*;

    fn codegen() -> CodegenConfig {
        toml::from_str(
            r#"
            features = ["core", "engine"]
            [modules]
            Engine = { module = "engine", feature = "engine" }
            [blocklist]
            classes = ["Foo"]
            "#,
        )
        .unwrap()
    }

    #[test]
    fn libraries_bind_rusteal_modules() {
        let runtime = codegen().for_library(LibraryKind::Runtime);
        assert_eq!(runtime.modules["Rusteal"].module, "rusteal");
        assert_eq!(runtime.modules["Rusteal"].feature, "core");
        assert!(!runtime.modules.contains_key("RustealEditor"));

        assert!(
            runtime
                .blocklist
                .classes
                .iter()
                .any(|c| c == "RustealDelegateProxy")
        );

        assert!(runtime.blocklist.classes.iter().any(|c| c == "Foo"));

        let editor = codegen().for_library(LibraryKind::Editor);
        assert_eq!(editor.modules["RustealEditor"].module, "rusteal_editor");
    }
}