factorio-frontend 0.1.2

Rust frontend that lowers Factorio mod sources into factorio-ir
Documentation
use std::fmt::Write;

use proc_macro2::TokenStream;
use syn::{
    Expr, LitStr, Token, Type,
    parse::{Parse, ParseStream},
};

use crate::error::{FrontendError, FrontendResult};

pub fn expand(tokens: TokenStream) -> FrontendResult<Vec<syn::Item>> {
    let input: ModSettingsInput =
        syn::parse2(tokens).map_err(|e| FrontendError::Syn(e.to_string()))?;

    let mut const_defs = String::new();
    let mut extend_items = String::new();

    for group in &input.groups {
        let stage_str = match group.stage {
            SettingStage::Startup => "startup",
            SettingStage::RuntimeGlobal => "runtime-global",
            SettingStage::RuntimePerUser => "runtime-per-user",
        };

        for entry in &group.entries {
            let const_name = entry.ident.to_string().to_uppercase();
            let lua_name = build_lua_name(input.prefix.as_deref(), &entry.ident.to_string());
            let default_str = expr_to_string(&entry.default);

            let _ = writeln!(
                const_defs,
                "pub const {const_name}: &'static str = \"{lua_name}\";"
            );

            let (struct_name, extra_fields) = match entry.setting_type {
                SettingType::Bool => ("BoolSetting", String::new()),
                SettingType::Int => (
                    "IntSetting",
                    "minimum_value: None, maximum_value: None,".to_string(),
                ),
                SettingType::Double => (
                    "DoubleSetting",
                    "minimum_value: None, maximum_value: None,".to_string(),
                ),
                SettingType::Str => ("StringSetting", "hidden: false,".to_string()),
            };

            let _ = writeln!(
                extend_items,
                "{struct_name} {{ name: \"{lua_name}\", setting_type: \"{stage_str}\", default_value: {default_str}, {extra_fields} }},"
            );
        }
    }

    let code = format!(
        "pub struct Settings; \
         impl Settings {{ {const_defs} }} \
         pub fn register() {{ data.extend([ {extend_items} ]); }}"
    );

    let file: syn::File = syn::parse_str(&code).map_err(|e| FrontendError::Syn(e.to_string()))?;
    Ok(file.items)
}

fn expr_to_string(expr: &Expr) -> String {
    use quote::ToTokens as _;
    expr.to_token_stream().to_string()
}

struct ModSettingsInput {
    prefix: Option<String>,
    groups: Vec<SettingGroup>,
}

struct SettingGroup {
    stage: SettingStage,
    entries: Vec<SettingEntry>,
}

struct SettingEntry {
    ident: syn::Ident,
    setting_type: SettingType,
    default: Expr,
}

#[derive(Clone, Copy)]
enum SettingStage {
    Startup,
    RuntimeGlobal,
    RuntimePerUser,
}

#[derive(Clone, Copy)]
enum SettingType {
    Bool,
    Int,
    Double,
    Str,
}

impl Parse for ModSettingsInput {
    fn parse(input: ParseStream<'_>) -> syn::Result<Self> {
        let mut prefix: Option<String> = None;
        if input.peek(syn::Ident) {
            let fork = input.fork();
            let kw: syn::Ident = fork.parse()?;
            if kw == "prefix" && fork.peek(Token![=]) {
                let _: syn::Ident = input.parse()?;
                let _: Token![=] = input.parse()?;
                let lit: LitStr = input.parse()?;
                prefix = Some(lit.value());
                let _: Option<Token![,]> = input.parse()?;
            }
        }

        let mut groups = Vec::new();
        while !input.is_empty() {
            groups.push(input.parse::<SettingGroup>()?);
        }

        Ok(Self { prefix, groups })
    }
}

impl Parse for SettingGroup {
    fn parse(input: ParseStream<'_>) -> syn::Result<Self> {
        let stage_kw: syn::Ident = input.parse()?;
        let stage = match stage_kw.to_string().as_str() {
            "startup" => SettingStage::Startup,
            "runtime_global" => SettingStage::RuntimeGlobal,
            "runtime_per_user" => SettingStage::RuntimePerUser,
            other => {
                return Err(syn::Error::new(
                    stage_kw.span(),
                    format!(
                        "unknown setting stage `{other}`; expected `startup`, `runtime_global`, or `runtime_per_user`"
                    ),
                ));
            }
        };

        let content;
        syn::braced!(content in input);

        let mut entries = Vec::new();
        while !content.is_empty() {
            entries.push(content.parse::<SettingEntry>()?);
        }

        Ok(Self { stage, entries })
    }
}

impl Parse for SettingEntry {
    fn parse(input: ParseStream<'_>) -> syn::Result<Self> {
        let ident: syn::Ident = input.parse()?;
        let _: Token![:] = input.parse()?;
        let ty: Type = input.parse()?;
        let _: Token![=] = input.parse()?;
        let default: Expr = input.parse()?;
        let _: Option<Token![,]> = input.parse()?;

        let setting_type = type_to_setting_type(&ty).ok_or_else(|| {
            syn::Error::new_spanned(
                &ty,
                "unsupported setting type; use `bool`, `i64`, `f64`, or `&'static str`",
            )
        })?;

        Ok(Self {
            ident,
            setting_type,
            default,
        })
    }
}

fn type_to_setting_type(ty: &Type) -> Option<SettingType> {
    match ty {
        Type::Path(tp) => {
            let ident = tp.path.get_ident()?.to_string();
            match ident.as_str() {
                "bool" => Some(SettingType::Bool),
                "i8" | "i16" | "i32" | "i64" | "u8" | "u16" | "u32" | "u64" | "usize" => {
                    Some(SettingType::Int)
                }
                "f32" | "f64" => Some(SettingType::Double),
                "String" => Some(SettingType::Str),
                _ => None,
            }
        }
        Type::Reference(tr) => {
            if let Type::Path(tp) = tr.elem.as_ref()
                && tp.path.is_ident("str")
            {
                Some(SettingType::Str)
            } else {
                None
            }
        }
        _ => None,
    }
}

fn build_lua_name(prefix: Option<&str>, snake: &str) -> String {
    let kebab = snake.replace('_', "-");
    match prefix {
        Some(p) => format!("{p}-{kebab}"),
        None => kebab,
    }
}