zenum 0.0.0-reserved

swiss-army knife of derives for enums
---
edition = "2024"

[dependencies]
fs-err = "3"
color-eyre = "0.6"
eyre = "0.6"
strum = { version = "0.27", features = ["derive"] }
---
// This script generates

use eyre::Result;
use fs_err as fs;

fn main() -> Result<()> {
    color_eyre::install()?;

    for file in fs::read_dir(concat!(env!("CARGO_MANIFEST_DIR"), "/src"))?.flatten() {
        let file_content = fs::read_to_string(file.path())?;
        let Some(new_content) = Marker::EnumParse.replace_content(&file_content) else {
            // this file doesn't have the generated marker
            continue;
        };
        let Some(new_content) = Marker::RenameAll.replace_content(&file_content) else {
            // this file doesn't have the generated marker
            continue;
        };

        fs::write(file.path(), new_content.as_bytes())?;
    }

    Ok(())
}

#[derive(Copy, Clone, strum::Display)]
#[strum(serialize_all = "snake_case")]
enum Marker {
    EnumParse,
    RenameAll,
}

impl Marker {
    fn content(self) -> String {
        let content = match self {
            Self::RenameAll => {
                r#"
const STREN_RENAME_ALL: Option<$crate::private::const_format::Case> = {
    match $crate::extract_field!(rename_all: $(#[$($enum_attr)*])*) {
        Some("lowercase") => Some($crate::private::const_format::Case::Lower),
        Some("UPPERCASE") => Some($crate::private::const_format::Case::Upper),
        Some("PascalCase") => Some($crate::private::const_format::Case::Pascal),
        Some("camelCase") => Some($crate::private::const_format::Case::Camel),
        Some("snake_case") => Some($crate::private::const_format::Case::Snake),
        Some("SCREAMING_SNAKE_CASE") => Some($crate::private::const_format::Case::UpperSnake),
        Some("kebab-case") => Some($crate::private::const_format::Case::Kebab),
        Some("SCREAMING-KEBAB-CASE") => Some($crate::private::const_format::Case::UpperKebab),
        Some(unknown) => panic!("invalid value for `#[stren(rename_all_fields)]`"),
        None => None
    }
};"#
            }
            Self::EnumParse => {
                r#"
$(#[$($enum_attr:tt)*])*
$enum_vis:vis enum $enum_ident:ident
    // a best-effort parsing of generics
    //
    // what's missing:
    //
    // - more than 1 lifetime bound (e.g. 'a: 'b + 'c)
    // - more than 1 trait bound (e.g. T: A + B + C)
    // - support for "use" TypeParam in trait bounds
    // - `const` type parameters are totally unsupported
    // - `for<..>` lifetimes in `where` clause
    $(<
        $(
            $(#[$($type_param_attr:tt)*])*
            // lifetime parameter
            $($type_param_lifetime:lifetime $(: $type_param_lifetime_super:lifetime)?)?
            // type parameter
            $($type_param_type:ident
                $(:
                    $($type_param_lifetime_bound:lifetime)?
                    $($type_param_type_bound:path)?
                )? $(= $type_param_default:ty)?
            )?
        ),*
        $(,)?
    >)?
    $(where $(
        $($where_lifetime:lifetime: $where_lifetime_bounds:lifetime)?
        $($where_type_param_ty:ty: $where_type_param_bounds:path)?
    ),*)?
{
    $(
        $(#[$($enum_variant_attr:tt)*])*
        $enum_variant:ident
            // enum with named fields
            $({
                $(
                    $(#[$($enum_variant_named_field_attr:tt)*])*
                    $enum_variant_named_field_ident:ident: $enum_variant_named_field_ty:ty
                ),* $(,)?
            })?
            // enum with unnamed fields
            $((
                $(
                    $(#[$($enum_variant_unnamed_field_attr:tt)*])*
                    $enum_variant_unnamed_field_ty:ty
                ),* $(,)?
            ))?
            // discriminant
            $(= $enum_variant_discriminant:expr)?
    ),* $(,)?
}"#
            }
        };

        format!(
            "{} NOTE: generated by `generate.rs`{content}\n// {}",
            self.opening(),
            self.closing()
        )
    }

    /// Returns `None` if no content was replaced, so the marker is not present in the file
    fn replace_content(self, content: &str) -> Option<String> {
        let (prefix, rest) = content.split_once(&self.opening())?;
        // This is the indentation of the marker line
        let indent = prefix
            .chars()
            .rev()
            // skip the "// "
            .skip(3)
            .take_while(|ch| *ch == ' ')
            .collect::<String>();
        let (mid, suffix) = rest.split_once(&self.closing())?;
        let content = self
            .content()
            .lines()
            .enumerate()
            .map(|(i, line)| {
                if i == 0 {
                    // Since we got the indentation from the first line, don't indent it
                    line.to_string()
                } else {
                    // Indent every other line
                    format!("{indent}{line}")
                }
            })
            .collect::<Vec<_>>()
            .join("\n");
        Some(format!("{prefix}{content}{suffix}"))
    }

    fn opening(self) -> String {
        format!("<{self}: start>")
    }

    fn closing(self) -> String {
        format!("<{self}: end>")
    }
}