bevy_state_plugin_generator 1.4.4

A build-dependency that generates a Bevy State Plugin from a simple state definition.
Documentation
use std::borrow::Cow;

use itertools::Itertools;
use lazy_regex::regex;

use crate::generate::core::get_package_info;
use crate::prelude::{NamingScheme, PluginConfig, PluginName};

#[derive(Clone, Debug, Default, PartialEq)]
pub(crate) struct TemplateHeader<'a> {
    pub template: Vec<&'a str>,
    pub comments_block: Vec<&'a str>,
    pub info_block: Vec<String>,
}

trait Unquote {
    fn unquoted(&self) -> Self;
}

impl Unquote for &str {
    fn unquoted(&self) -> Self {
        self.strip_prefix("\"")
            .and_then(|s| s.strip_suffix("\""))
            .unwrap_or(self)
    }
}

pub(crate) const SUPPORTED_VARIABLES: &[&str] = &[
    "root_state_name",
    "naming_scheme",
    "plugin_name",
    "states_module_name",
    "additional_derives",
];

pub(crate) fn parse_template_header<'a>(
    source: &'a str,
    plugin_config: &mut PluginConfig,
) -> TemplateHeader<'a> {
    let leading_comments = source
        .lines()
        .take_while(|line| line.starts_with("//"))
        .collect_vec();

    let mut info_block = Vec::new();
    info_block.push(format!("generated by {}", get_package_info()));

    macro_rules! emit_warning {
        ($($exprs:expr),*) => {
            let warning = format!($($exprs),*);
            let warning = format!("WARN: {warning}");
            eprintln!("{warning}");
            info_block.push(warning);
        };
    }

    let mut template_source = Vec::new();
    let mut in_template = false;
    let leading_comments = leading_comments
        .iter()
        .filter(|line| {
            if in_template {
                if let Some(line) = line.strip_prefix("//") {
                    template_source.push(line.trim());
                    true
                } else {
                    false
                }
            } else if let Some(captures) =
                regex!(r#"^\s*//\s*bspg:(\w+)\s+(\w+|"\w+\")\s*$"#).captures(line)
            {
                let (_, [name, value]) = captures.extract();
                emit_warning!("unknown setting: '{name}'");
                if !SUPPORTED_VARIABLES.contains(&name) {
                    emit_warning!("unknown setting: '{name}'");
                } else {
                    match name {
                        "root_state_name" => {
                            plugin_config.root_state_name = if value == "None" {
                                None
                            } else {
                                Some(Cow::Owned(value.unquoted().to_string()))
                            };
                        }
                        "naming_scheme" => {
                            if let Some(naming_scheme) = NamingScheme::try_parse(value) {
                                plugin_config.naming_scheme = naming_scheme;
                            } else {
                                emit_warning!(
                                    "invalid naming scheme '{value}' (expected [none, short, full])"
                                );
                            }
                        }
                        "plugin_name" => {
                            let value = value.to_string();
                            plugin_config.plugin_name =
                                if value.starts_with(|c: char| c.is_uppercase()) {
                                    PluginName::new_struct(value)
                                } else {
                                    PluginName::new_function(value)
                                }
                        }
                        "states_module_name" => {
                            plugin_config.states_module_name = Cow::from(value.to_string());
                        }
                        "additional_derives" => {
                            let mut to_add = value
                                .split_terminator(",")
                                .map(ToString::to_string)
                                .map(Cow::Owned)
                                .collect_vec();
                            plugin_config.additional_derives.append(&mut to_add);
                        }
                        _ => {
                            unreachable!()
                        }
                    }
                }
                true
            } else if regex!(r#"^\s*//\s*bspg:\s*$"#).is_match(line) {
                in_template = true;
                true
            } else {
                eprintln!("dropping: '{line:?}'");
                false
            }
        })
        .copied()
        .collect_vec();
    TemplateHeader {
        template: template_source,
        comments_block: leading_comments,
        info_block,
    }
}

#[cfg(test)]
mod tests {
    use bevy_reflect::Struct;
    use bevy_utils::default;
    use indoc::formatdoc;
    use rstest::rstest;
    use speculoos::assert_that;
    use speculoos::prelude::{ContainingIntoIterAssertions, VecAssertions};

    use crate::parsing::header::{SUPPORTED_VARIABLES, parse_template_header};
    use crate::prelude::{NamingScheme, PluginConfig};

    #[rstest]
    #[case(String::new())]
    #[case::ignore_generated_lines(formatdoc! {"
        // generated by v[CARGO_PKG_VERSION]
        // WARN: some warning
    "})]
    fn test_parse_template_header(#[case] header: String) {
        let mut config: PluginConfig = default();
        let header = parse_template_header(&header, &mut config);
        assert_that!(header.template).is_empty();
        assert_that!(header.comments_block).is_empty();
    }

    #[rstest]
    fn test_parse_template_header_keep_variables() {
        let header = formatdoc! {"
            // generated by v[CARGO_PKG_VERSION]
            // WARN: some warning
            // bspg:some setting
        "};
        let mut config: PluginConfig = default();
        let header = parse_template_header(&header, &mut config);
        assert_that!(header.comments_block).contains("// bspg:some setting");
        assert_that!(header.info_block).contains("WARN: unknown setting: 'some'".to_string());
    }

    #[rstest]
    fn test_parse_template_header_variable_values(
        #[values(NamingScheme::Full, NamingScheme::None, NamingScheme::Short)]
        naming_scheme: NamingScheme,
        #[values(None, Some("MyStateRoot"))] name: Option<&str>,
    ) {
        let mut config: PluginConfig = default();
        let header = formatdoc! {"
            // generated by v[CARGO_PKG_VERSION]
            // bspg:root_state_name {}
            // bspg:naming_scheme   {}
            // bspg:
            ",
            name.map(|name| format!("\"{name}\"")).unwrap_or("None".to_string()),
            naming_scheme
        };
        let _ = parse_template_header(&header, &mut config);
        assert_that!(config.root_state_name.as_ref().map(ToString::to_string))
            .is_equal_to(name.map(String::from));
        assert_that!(config.naming_scheme).is_equal_to(naming_scheme);
    }

    #[rstest]
    fn test_plugin_config_all_fields_supported_as_variables() {
        let config = PluginConfig::default();
        let info = config.get_represented_struct_info().unwrap();
        assert_that!(info.field_names().to_vec()).contains_all_of(&SUPPORTED_VARIABLES);
    }

    #[rstest]
    fn test_parse_template_header_all_variables_supported() {}
}