use std::path::Path;
use std::fmt::Write;
use factorio_codegen::LuaGenerator;
use factorio_ir::{module::Module, stage::Stage, statement::Statement};
use heck::AsLowerCamelCase;
use serde::Serialize;
use crate::{
cargo_manifest::CargoPackage,
config::{Config, ModConfig},
error::{CliError, CliResult},
};
#[derive(Debug, Clone, PartialEq)]
pub struct EventRegistration {
pub module: String,
pub handler: String,
pub event: String,
pub filter: Option<factorio_ir::expression::Expression>,
}
pub fn collect_event_registrations(module: &Module) -> Vec<EventRegistration> {
if module.stage != Stage::Control {
return Vec::new();
}
module
.symbols
.iter()
.filter_map(|symbol| {
let Statement::FunctionDecl(function) = &symbol.statement else {
return None;
};
let event_name = function.event.as_ref()?;
Some(EventRegistration {
module: module.name.clone(),
handler: function.name.clone(),
event: event_name.clone(),
filter: function.event_filter.clone(),
})
})
.collect()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StageModule {
pub name: String,
pub entry_functions: Vec<String>,
}
pub fn collect_stage_module(module: &Module, stage: Stage) -> Option<StageModule> {
if module.stage != stage {
return None;
}
let entry_functions = module
.symbols
.iter()
.filter_map(|symbol| {
let Statement::FunctionDecl(function) = &symbol.statement else {
return None;
};
Some(function.name.clone())
})
.collect();
Some(StageModule {
name: module.name.clone(),
entry_functions,
})
}
fn generated_header(profile: &str) -> String {
format!(
"-- Generated by factorio-rs@{}\n-- Profile: {profile}\n",
env!("CARGO_PKG_VERSION"),
)
}
pub fn generate_stage_entry_lua(
mod_name: &str,
stage_modules: &[StageModule],
stage: Stage,
module_prefix: &str,
profile: &str,
) -> String {
let mut output = generated_header(profile);
let roots: Vec<&StageModule> = stage_modules
.iter()
.filter(|module| {
!stage_modules.iter().any(|other| {
other.name != module.name && module.name.starts_with(&format!("{}.", other.name))
})
})
.collect();
if roots.is_empty() {
output.push_str("-- No modules registered for this stage.\n");
return output;
}
for module in roots {
let lua_path = prefixed_lua_path(&module.name, module_prefix);
let raw_table_name = module_lua_identifier(&module.name);
let table_name = if module_prefix.is_empty() {
raw_table_name
} else {
format!("{module_prefix}_{raw_table_name}")
};
let _ = writeln!(
output,
"local {table_name} = require(\"__{mod_name}__/lua/{lua_path}\")"
);
for function in &module.entry_functions {
let _ = writeln!(output, "{table_name}.{function}()");
}
output.push('\n');
}
let _ = stage;
output
}
pub fn generate_control_lua(
mod_name: &str,
events: &[EventRegistration],
module_prefix: &str,
profile: &str,
) -> String {
let mut output = generated_header(profile);
if events.is_empty() {
output.push_str("-- No event handlers registered.\n");
return output;
}
let generator = LuaGenerator::new();
let mut emitted_requires: Vec<String> = Vec::new();
for event in events {
let lua_module_path = prefixed_lua_path(&event.module, module_prefix);
let require_path = format!("__{mod_name}__/lua/{lua_module_path}");
let table_name = module_lua_identifier(&event.module);
if !emitted_requires.contains(&event.module) {
let _ = writeln!(output, "local {table_name} = require(\"{require_path}\")");
emitted_requires.push(event.module.clone());
}
let _ = writeln!(
output,
"script.on_event(defines.events.{}, function(event)",
event.event
);
let _ = writeln!(output, "\t{table_name}.{}(event)", event.handler);
if let Some(filter) = &event.filter {
let filter_lua = generator.generate_expression(filter);
let _ = writeln!(output, "end, {filter_lua})");
} else {
let _ = writeln!(output, "end)");
}
output.push('\n');
}
output
}
fn prefixed_lua_path(module_name: &str, prefix: &str) -> String {
if prefix.is_empty() {
return module_name.replace('.', "/");
}
module_name.rfind('.').map_or_else(
|| format!("{prefix}_{module_name}"),
|dot| {
let dir = module_name[..dot].replace('.', "/");
format!("{dir}/{prefix}_{}", &module_name[dot + 1..])
},
)
}
#[derive(Debug, Serialize)]
struct InfoJson<'a> {
name: &'a str,
version: &'a str,
title: &'a str,
author: &'a str,
factorio_version: &'a str,
dependencies: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<&'a str>,
}
pub fn generate_info_json(package: &CargoPackage, mod_config: &ModConfig) -> CliResult<String> {
let author = package.author_label();
let factorio_version = mod_config.factorio_version.as_deref().unwrap_or("2.0");
let base_dependency = format!("base >= {factorio_version}");
let info = InfoJson {
name: &package.name,
version: &package.version,
title: mod_config.title.as_deref().unwrap_or(&package.name),
author: author.as_str(),
factorio_version,
dependencies: vec![base_dependency],
description: mod_config.description.as_deref(),
};
serde_json::to_string_pretty(&info).map_err(|source| CliError::InfoJsonSerialize { source })
}
pub struct StageModules {
pub entries: Vec<(Stage, StageModule)>,
}
impl StageModules {
#[must_use]
pub const fn new() -> Self {
Self {
entries: Vec::new(),
}
}
pub fn push(&mut self, stage: Stage, module: StageModule) {
self.entries.push((stage, module));
}
fn modules_for(&self, stage: Stage) -> Vec<StageModule> {
self.entries
.iter()
.filter(|(s, _)| *s == stage)
.map(|(_, module)| module.clone())
.collect()
}
}
impl Default for StageModules {
fn default() -> Self {
Self::new()
}
}
pub fn write_mod_manifests(
output_dir: &Path,
package: &CargoPackage,
config: &Config,
events: &[EventRegistration],
stage_modules: &StageModules,
profile: &str,
) -> CliResult<()> {
let module_prefix = config.emit.lua_module_prefix.as_deref().unwrap_or("");
let info_json = generate_info_json(package, &config.r#mod)?;
write_file(output_dir, "info.json", &info_json)?;
let control_lua = generate_control_lua(&package.name, events, module_prefix, profile);
write_file(output_dir, "control.lua", &control_lua)?;
for stage in Stage::SIDE_EFFECT_STAGES {
let modules = stage_modules.modules_for(stage);
if modules.is_empty() {
continue;
}
let Some(entry_file) = stage.entry_file_name() else {
continue;
};
let lua = generate_stage_entry_lua(&package.name, &modules, stage, module_prefix, profile);
write_file(output_dir, entry_file, &lua)?;
}
Ok(())
}
fn write_file(output_dir: &Path, name: &str, contents: &str) -> CliResult<()> {
let path = output_dir.join(name);
std::fs::write(&path, contents).map_err(|source| CliError::WriteFile { path, source })
}
fn module_lua_identifier(module_name: &str) -> String {
AsLowerCamelCase(module_name.replace('.', "_")).to_string()
}
#[cfg(test)]
mod tests {
use factorio_ir::{
block::Block,
expression::Expression,
function::Function,
literal::Literal,
module::{Module, Symbol},
scope::Scope,
stage::Stage,
statement::Statement,
};
use super::{
StageModule, StageModules, collect_event_registrations, collect_stage_module,
generate_control_lua, generate_stage_entry_lua, write_mod_manifests,
};
fn make_control_module(name: &str, event: &str) -> Module {
Module {
name: name.to_string(),
stage: Stage::Control,
body: Block { statements: vec![] },
imports: vec![],
submodules: vec![],
locales: vec![],
symbols: vec![Symbol {
scope: Scope::Public,
statement: Statement::FunctionDecl(Function {
name: event.to_string(),
params: vec![],
body: Block { statements: vec![] },
doc: None,
debug: None,
event: Some(event.to_string()),
event_filter: None,
}),
}],
}
}
fn make_stage_module(name: &str, stage: Stage) -> Module {
Module {
name: name.to_string(),
stage,
body: Block { statements: vec![] },
imports: vec![],
submodules: vec![],
locales: vec![],
symbols: vec![],
}
}
#[test]
fn generates_control_lua_with_event_handler() {
let module = make_control_module("control.on_singleplayer_init", "on_singleplayer_init");
let events = collect_event_registrations(&module);
let lua = generate_control_lua("hello_world", &events, "", "debug");
assert!(lua.contains("require(\"__hello_world__/lua/control/on_singleplayer_init\")"));
assert!(
lua.contains("script.on_event(defines.events.on_singleplayer_init, function(event)")
);
assert!(lua.contains("controlOnSingleplayerInit.on_singleplayer_init(event)"));
assert!(lua.contains("end)"));
}
#[test]
fn generates_control_lua_with_event_filter() {
let module = Module {
name: "control.on_built_entity".to_string(),
stage: Stage::Control,
body: Block { statements: vec![] },
imports: vec![],
submodules: vec![],
locales: vec![],
symbols: vec![Symbol {
scope: Scope::Public,
statement: Statement::FunctionDecl(Function {
name: "on_built_entity".to_string(),
params: vec![],
body: Block { statements: vec![] },
doc: None,
debug: None,
event: Some("on_built_entity".to_string()),
event_filter: Some(Expression::Array {
elements: vec![Expression::StructLiteral {
struct_name: None,
fields: vec![
(
"filter".to_string(),
Expression::Literal(Literal::String("type".to_string())),
),
(
"type".to_string(),
Expression::Literal(Literal::String("inserter".to_string())),
),
],
}],
}),
}),
}],
};
let events = collect_event_registrations(&module);
let lua = generate_control_lua("hello_world", &events, "", "debug");
assert!(lua.contains("require(\"__hello_world__/lua/control/on_built_entity\")"));
assert!(lua.contains("script.on_event(defines.events.on_built_entity, function(event)"));
assert!(lua.contains("end, { { filter = \"type\", type = \"inserter\" } })"));
assert!(lua.contains("controlOnBuiltEntity.on_built_entity(event)"));
}
#[test]
fn generates_data_lua_for_single_root_module() {
let modules = vec![StageModule {
name: "data".to_string(),
entry_functions: vec!["register".to_string()],
}];
let lua = generate_stage_entry_lua("my_mod", &modules, Stage::Data, "", "debug");
assert!(lua.contains("local data = require(\"__my_mod__/lua/data\")"));
assert!(lua.contains("data.register()"));
assert!(!lua.contains("require(\"__my_mod__/lua/data/items\")"));
}
#[test]
fn generates_data_lua_omits_children_when_parent_present() {
let modules = vec![
StageModule {
name: "data".to_string(),
entry_functions: vec!["register".to_string()],
},
StageModule {
name: "data.items".to_string(),
entry_functions: vec!["register".to_string()],
},
];
let lua = generate_stage_entry_lua("my_mod", &modules, Stage::Data, "", "debug");
assert!(lua.contains("local data = require(\"__my_mod__/lua/data\")"));
assert!(lua.contains("data.register()"));
assert!(!lua.contains("data.items"));
}
#[test]
fn generates_data_lua_requires_multiple_root_modules() {
let modules = vec![
StageModule {
name: "data.items".to_string(),
entry_functions: vec!["register_items".to_string()],
},
StageModule {
name: "data.entities".to_string(),
entry_functions: vec!["register_entities".to_string()],
},
];
let lua = generate_stage_entry_lua("my_mod", &modules, Stage::Data, "", "debug");
assert!(lua.contains("local dataItems = require(\"__my_mod__/lua/data/items\")"));
assert!(lua.contains("dataItems.register_items()"));
assert!(lua.contains("local dataEntities = require(\"__my_mod__/lua/data/entities\")"));
assert!(lua.contains("dataEntities.register_entities()"));
}
#[test]
fn generates_settings_lua() {
let modules = vec![StageModule {
name: "settings".to_string(),
entry_functions: vec!["register".to_string()],
}];
let lua = generate_stage_entry_lua("my_mod", &modules, Stage::Settings, "", "debug");
assert!(lua.contains("local settings = require(\"__my_mod__/lua/settings\")"));
assert!(lua.contains("settings.register()"));
}
#[test]
fn generates_settings_updates_and_final_fixes_lua() {
let updates = vec![StageModule {
name: "settings_updates".to_string(),
entry_functions: vec!["patch".to_string()],
}];
let updates_lua =
generate_stage_entry_lua("my_mod", &updates, Stage::SettingsUpdates, "", "debug");
assert!(
updates_lua
.contains("local settingsUpdates = require(\"__my_mod__/lua/settings_updates\")")
);
assert!(updates_lua.contains("settingsUpdates.patch()"));
let finals = vec![StageModule {
name: "settings_final_fixes".to_string(),
entry_functions: vec!["fixup".to_string()],
}];
let finals_lua =
generate_stage_entry_lua("my_mod", &finals, Stage::SettingsFinalFixes, "", "debug");
assert!(finals_lua.contains(
"local settingsFinalFixes = require(\"__my_mod__/lua/settings_final_fixes\")"
));
assert!(finals_lua.contains("settingsFinalFixes.fixup()"));
}
#[test]
fn generates_data_updates_and_final_fixes_lua() {
let updates = vec![StageModule {
name: "data_updates".to_string(),
entry_functions: vec!["patch".to_string()],
}];
let updates_lua =
generate_stage_entry_lua("my_mod", &updates, Stage::DataUpdates, "", "debug");
assert!(
updates_lua.contains("local dataUpdates = require(\"__my_mod__/lua/data_updates\")")
);
assert!(updates_lua.contains("dataUpdates.patch()"));
let finals = vec![StageModule {
name: "data_final_fixes".to_string(),
entry_functions: vec!["fixup".to_string()],
}];
let finals_lua =
generate_stage_entry_lua("my_mod", &finals, Stage::DataFinalFixes, "", "debug");
assert!(
finals_lua
.contains("local dataFinalFixes = require(\"__my_mod__/lua/data_final_fixes\")")
);
assert!(finals_lua.contains("dataFinalFixes.fixup()"));
}
#[test]
fn write_mod_manifests_emits_all_settings_phase_files() {
use std::fs;
use tempfile::tempdir;
use crate::{cargo_manifest::CargoPackage, config::Config};
let dir = tempdir().expect("tempdir");
let package = CargoPackage {
name: "phase_mod".to_string(),
version: "0.1.0".to_string(),
authors: Some(vec!["test".to_string()]),
};
let config: Config = toml::from_str("").expect("default config");
let mut stage_modules = StageModules::new();
stage_modules.push(
Stage::Settings,
StageModule {
name: "settings".to_string(),
entry_functions: vec!["register".to_string()],
},
);
stage_modules.push(
Stage::SettingsUpdates,
StageModule {
name: "settings_updates".to_string(),
entry_functions: vec!["patch".to_string()],
},
);
stage_modules.push(
Stage::SettingsFinalFixes,
StageModule {
name: "settings_final_fixes".to_string(),
entry_functions: vec!["fixup".to_string()],
},
);
write_mod_manifests(dir.path(), &package, &config, &[], &stage_modules, "debug")
.expect("write manifests");
assert!(dir.path().join("settings.lua").is_file());
assert!(dir.path().join("settings-updates.lua").is_file());
assert!(dir.path().join("settings-final-fixes.lua").is_file());
let updates = fs::read_to_string(dir.path().join("settings-updates.lua")).unwrap();
assert!(updates.contains("settingsUpdates.patch()"));
let finals = fs::read_to_string(dir.path().join("settings-final-fixes.lua")).unwrap();
assert!(finals.contains("settingsFinalFixes.fixup()"));
}
#[test]
fn collect_stage_module_returns_exports_for_matching_stage() {
let data_mod = make_stage_module("data.items", Stage::Data);
let ctrl_mod = make_stage_module("control", Stage::Control);
assert_eq!(
collect_stage_module(&data_mod, Stage::Data),
Some(StageModule {
name: "data.items".to_string(),
entry_functions: vec![],
})
);
assert_eq!(collect_stage_module(&ctrl_mod, Stage::Data), None);
}
#[test]
fn settings_stage_modules_are_not_collected_as_data() {
let settings_mod = make_stage_module("settings", Stage::Settings);
assert_eq!(collect_stage_module(&settings_mod, Stage::Data), None);
assert_eq!(
collect_stage_module(&settings_mod, Stage::Settings),
Some(StageModule {
name: "settings".to_string(),
entry_functions: vec![],
})
);
}
#[test]
fn transpiles_bool_settings_registration() {
use factorio_codegen::LuaGenerator;
use factorio_frontend::parse_module;
let source = r#"
pub fn register() {
data.extend([
BoolSetting { name: "ms-casual-mode", setting_type: "startup", default_value: false },
BoolSetting { name: "ms-adjacency-enabled", setting_type: "startup", default_value: true },
]);
}
"#;
let module = parse_module(source, "settings").expect("parse settings");
let lua = LuaGenerator::with_mod_name("mandatory_spaghetti")
.generate_module(&module)
.expect("generate settings lua");
assert!(lua.contains("function settings.register()"));
assert!(lua.contains("data.extend("));
assert!(lua.contains("type = \"bool-setting\""));
assert!(lua.contains("name = \"ms-casual-mode\""));
assert!(lua.contains("default_value = false"));
assert!(lua.contains("name = \"ms-adjacency-enabled\""));
assert!(lua.contains("default_value = true"));
let entry_lua = generate_stage_entry_lua(
"mandatory_spaghetti",
&[StageModule {
name: "settings".to_string(),
entry_functions: vec!["register".to_string()],
}],
Stage::Settings,
"",
"release",
);
assert!(entry_lua.contains("-- Profile: release"));
assert!(entry_lua.contains("settings.register()"));
}
}