factorio-codegen 0.1.2

Lua code generator for factorio-rs
Documentation
use std::fmt::Write as _;

use factorio_ir::{block::Block, literal::Literal, module::Module, scope::Scope};

use crate::generator::error::LuaGeneratorResult;

pub mod comment;
pub mod error;
pub mod expression;
pub mod function;
pub mod operator;
pub mod statement;
pub mod table;

pub struct LuaGenerator {
    output: String,
    indent_level: usize,
    /// Rewrites `StructName.associated` paths while generating struct methods.
    struct_table_context: Option<(String, String)>,
    debug_level: Option<u8>,
    mod_name: String,
    /// Depth of nested `for` loops, used to generate unique `::__continue_N::` labels.
    for_depth: usize,
    /// Optional prefix prepended to every module's filename and local require variable.
    /// Empty string means no prefix.
    module_prefix: String,
    /// Active transpile profile name (`debug`, `release`, ...), if known.
    profile: Option<String>,
}

impl Default for LuaGenerator {
    fn default() -> Self {
        Self::new()
    }
}

impl LuaGenerator {
    #[must_use]
    pub fn new() -> Self {
        Self::with_mod_name("mod")
    }

    #[must_use]
    pub fn with_mod_name(mod_name: impl Into<String>) -> Self {
        Self {
            output: String::new(),
            indent_level: 0,
            struct_table_context: None,
            debug_level: None,
            mod_name: mod_name.into(),
            for_depth: 0,
            module_prefix: String::new(),
            profile: None,
        }
    }

    #[must_use]
    pub fn with_debug_level(debug_level: u8) -> Self {
        Self {
            output: String::new(),
            indent_level: 0,
            struct_table_context: None,
            debug_level: Some(debug_level),
            mod_name: "mod".to_string(),
            for_depth: 0,
            module_prefix: String::new(),
            profile: None,
        }
    }

    #[must_use]
    pub fn with_mod_name_and_debug(mod_name: impl Into<String>, debug_level: u8) -> Self {
        Self {
            output: String::new(),
            indent_level: 0,
            struct_table_context: None,
            debug_level: Some(debug_level),
            mod_name: mod_name.into(),
            for_depth: 0,
            module_prefix: String::new(),
            profile: None,
        }
    }

    /// Set the module prefix applied to all generated module filenames and local names.
    #[must_use]
    pub fn with_module_prefix(mut self, prefix: impl Into<String>) -> Self {
        self.module_prefix = prefix.into();
        self
    }

    /// Record the transpile profile name in generated file headers.
    #[must_use]
    pub fn with_profile(mut self, profile: impl Into<String>) -> Self {
        self.profile = Some(profile.into());
        self
    }

    const fn debug_level_at_least(&self, level: u8) -> bool {
        matches!(self.debug_level, Some(current) if current >= level)
    }

    pub const INDENT: &'static str = "\t";

    /// Returns a string indented to [`LuaGenerator::indent_level`].
    fn indent(&self) -> String {
        Self::INDENT.repeat(self.indent_level)
    }

    /// Writes a line of text to [`LuaGenerator::output`] terminated by a newline `\n`.
    fn write_line(&mut self, line: &str) {
        if !line.is_empty() {
            self.output.push_str(&self.indent());
            self.output.push_str(line);
        }
        self.output.push('\n');
    }

    fn write_doc_comments(&mut self, doc: Option<&str>) {
        let Some(doc) = doc else {
            return;
        };

        for line in doc.lines() {
            if line.is_empty() {
                self.write_line("--");
            } else {
                self.write_line(&format!("-- {line}"));
            }
        }
    }

    pub const MODULE_META_START: &'static str =
        concat!("-- Generated by factorio-rs@", env!("CARGO_PKG_VERSION"));

    fn generate_module_meta(&self, module: &Module) -> String {
        let mut header = format!("{}\n", Self::MODULE_META_START);
        if let Some(profile) = &self.profile {
            let _ = writeln!(header, "-- Profile: {profile}");
        }
        let _ = write!(header, "-- Module: `{}`", module.name);
        header
    }

    /// Returns the module identifier that exported symbols will be attached to, e.g
    /// `bound_detector` -> `boundDetector`, `player.extra_info` -> `playerExtraInfo`.
    fn module_identifier(module_name: &str) -> String {
        heck::AsLowerCamelCase(module_name.replace('.', "_")).to_string()
    }

    fn mod_require_path(&self, module_path: &str) -> String {
        let path = module_path.replace('.', "/");
        let prefixed_path = self.apply_path_prefix(&path);
        format!("__{}__/lua/{}", self.mod_name, prefixed_path)
    }

    #[must_use]
    pub fn apply_path_prefix(&self, path: &str) -> String {
        if self.module_prefix.is_empty() {
            return path.to_string();
        }
        path.rfind('/').map_or_else(
            || format!("{}_{}", self.module_prefix, path),
            |slash| {
                format!(
                    "{}/{prefix}_{}",
                    &path[..slash],
                    &path[slash + 1..],
                    prefix = self.module_prefix
                )
            },
        )
    }

    /// Return the prefixed local variable name for a module import.
    #[must_use]
    pub fn prefixed_local(&self, local: &str) -> String {
        if self.module_prefix.is_empty() {
            local.to_string()
        } else {
            format!("{}_{}", self.module_prefix, local)
        }
    }

    /// Generate lua code for a single `module`.
    ///
    /// # Errors
    /// Returns `Err` if parsing the AST fails.
    pub fn generate_module(&mut self, module: &Module) -> LuaGeneratorResult<String> {
        self.output.clear();
        self.indent_level = 0;

        let module_header = self.generate_module_meta(module);
        self.output.push_str(&module_header);
        self.output.push('\n');

        self.generate_imports(&module.imports);

        for statement in &module.body.statements {
            self.generate_statement(statement, Some(module), None, Scope::Private)?;
        }

        let module_name = Self::module_identifier(&module.name);
        let (exporter_start, exporter_end) = Self::generate_symbol_exporter(&module_name);
        self.write_line(&exporter_start);

        if !module.submodules.is_empty() {
            self.write_line(&format!(
                "package.loaded[\"{}\"] = {module_name}",
                self.mod_require_path(&module.name)
            ));
        }

        for symbol in &module.symbols {
            self.generate_statement(
                &symbol.statement,
                Some(module),
                Some(&module_name),
                symbol.scope,
            )?;
        }

        self.generate_submodules(&module.submodules);

        self.write_line(&exporter_end);

        Ok(self.output.clone())
    }

    fn generate_imports(&mut self, imports: &[factorio_ir::module::ModuleImport]) {
        for import in imports {
            // `import.local` already has the module prefix baked in at the IR level.
            self.write_line(&format!(
                "local {} = require(\"{}\")",
                import.local,
                self.mod_require_path(&import.module)
            ));

            for item in &import.items {
                self.write_line(&format!(
                    "local {} = {}.{}",
                    item.local, import.local, item.name
                ));
            }
        }

        if !imports.is_empty() {
            self.output.push('\n');
        }
    }

    fn generate_submodules(&mut self, submodules: &[String]) {
        for submodule in submodules {
            self.write_line(&format!(
                "require(\"{}\")",
                self.mod_require_path(submodule)
            ));
        }

        if !submodules.is_empty() {
            self.output.push('\n');
        }
    }

    /// Return the starting and ending lines for exporting symbols.
    fn generate_symbol_exporter(module_name: &str) -> (String, String) {
        (
            format!("local {module_name} = {{}}"),
            format!("return {module_name}"),
        )
    }

    fn generate_block(&mut self, block: &Block, module: Option<&Module>) -> LuaGeneratorResult<()> {
        for statement in &block.statements {
            self.generate_statement(statement, module, None, Scope::Private)?;
        }
        Ok(())
    }

    fn generate_literal(literal: &Literal) -> String {
        match literal {
            Literal::Int(value) => value.to_string(),
            Literal::Float(value) => value.to_string(),
            Literal::String(value) => format!("\"{}\"", escape_lua_string(value)),
            Literal::Bool(value) => value.to_string(),
            Literal::Nil => "nil".to_string(),
        }
    }

    fn format_parameter(&self, parameter: &factorio_ir::function::Parameter) -> String {
        let type_comment = self.parameter_type_comment(parameter.source_type.as_deref());
        format!("{}{type_comment}", parameter.name)
    }
}

fn escape_lua_string(value: &str) -> String {
    value
        .replace('\\', "\\\\")
        .replace('"', "\\\"")
        .replace('\n', "\\n")
        .replace('\r', "\\r")
        .replace('\t', "\\t")
}