bevy_grimoire_core 0.4.0

Engine-free world-building core: constraint IR, solvers, WFC, deterministic RNG.
Documentation
//! **Names that are also filenames** — one spelling, forced at the point of entry.
//!
//! A map carries a name, and that name becomes a file on disk, a key in a manifest, and something a
//! person types into a terminal. Those have different tolerances: a filesystem will happily accept
//! `My Map (final) v2.ron`, a shell will not without quoting, and two of them differing only by case
//! are the same file on one platform and two on another.
//!
//! So there is one spelling and it is **snake_case**: lowercase ASCII letters, digits, and single
//! underscores, starting with a letter. Everything the project already names this way agrees —
//! `wall_doorway_wide`, `mess_table`, `furniture_kenney` — so this is the existing convention being
//! enforced rather than a new one being imposed.
//!
//! # Forced, not merely validated
//!
//! [`to_snake_case`] is the important half. Rejecting a bad name tells an author they typed something
//! wrong and leaves them to work out what would be right; transforming it as they type means the
//! illegal state is never reachable. The editor filters keystrokes through this, so "Site 67" becomes
//! `site_67` while it is being typed rather than being refused when it is finished.
//!
//! [`is_snake_case`] stays because a file on disk was not necessarily typed here.

/// Is this already the one spelling?
pub fn is_snake_case(s: &str) -> bool {
    !s.is_empty()
        && s.starts_with(|c: char| c.is_ascii_lowercase())
        && s.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
        && !s.contains("__")
        && !s.ends_with('_')
}

/// Force a string into the one spelling.
///
/// Case folds, word boundaries become single underscores, and anything that is not a letter or digit
/// is dropped. `camelCase` and `PascalCase` split on the case change, so `SiteSixtySeven` becomes
/// `site_sixty_seven` rather than `sitesixtyseven` — the boundary is real information and throwing it
/// away makes the result unreadable.
///
/// Returns an empty string when nothing survives, which callers must treat as "no name yet" rather
/// than substituting one. A default name would be a second thing called `untitled` the first time two
/// people used it.
pub fn to_snake_case(s: &str) -> String {
    let chars: Vec<char> = s.chars().collect();
    let mut out = String::with_capacity(s.len() + 4);
    let mut pending_break = false;

    for (i, &c) in chars.iter().enumerate() {
        if c.is_ascii_alphanumeric() {
            // A lower→upper transition is a word boundary an author can see, so keep it. `HTTPServer`
            // splits before the last capital of a run, giving `http_server`, which is what a reader
            // expects even though it is the awkward case.
            let starts_word = c.is_ascii_uppercase()
                && i > 0
                && (chars[i - 1].is_ascii_lowercase()
                    || chars[i - 1].is_ascii_digit()
                    || (chars[i - 1].is_ascii_uppercase()
                        && chars.get(i + 1).is_some_and(char::is_ascii_lowercase)));
            if (pending_break || starts_word) && !out.is_empty() {
                out.push('_');
            }
            out.push(c.to_ascii_lowercase());
            pending_break = false;
        } else {
            // Any run of separators or junk collapses to one boundary, and a boundary before the
            // first character is nothing at all.
            pending_break = !out.is_empty();
        }
    }

    // A name must start with a letter, so lead digits are dropped rather than prefixed — `2nd_floor`
    // silently becoming `n_2nd_floor` would be a name nobody typed.
    let out = out.trim_start_matches(|c: char| c.is_ascii_digit() || c == '_');
    out.trim_end_matches('_').to_owned()
}

/// The file a named map is written to.
pub fn map_file_name(name: &str) -> String {
    format!("{name}.map.ron")
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn already_snake_is_left_alone() {
        for s in ["site_67", "a", "wall_doorway_wide", "kit_b2"] {
            assert!(is_snake_case(s), "{s} should be valid");
            assert_eq!(to_snake_case(s), s);
        }
    }

    #[test]
    fn the_invalid_shapes_are_named() {
        for s in ["", "Site", "site 67", "site-67", "_site", "site_", "site__67", "67"] {
            assert!(!is_snake_case(s), "{s} should be invalid");
        }
    }

    #[test]
    fn spaces_and_punctuation_become_one_underscore() {
        assert_eq!(to_snake_case("Site 67"), "site_67");
        assert_eq!(to_snake_case("my map (final) v2"), "my_map_final_v2");
        assert_eq!(to_snake_case("  lots   of   space  "), "lots_of_space");
        assert_eq!(to_snake_case("dash-and_underscore"), "dash_and_underscore");
    }

    /// A case boundary is information a reader uses, so splitting on it beats folding it away.
    #[test]
    fn case_boundaries_are_word_boundaries() {
        assert_eq!(to_snake_case("SiteSixtySeven"), "site_sixty_seven");
        assert_eq!(to_snake_case("camelCase"), "camel_case");
        assert_eq!(to_snake_case("HTTPServer"), "http_server");
    }

    /// A name has to start with a letter. Leading digits are dropped rather than prefixed: inventing
    /// a character produces a name the author never typed.
    #[test]
    fn leading_digits_are_dropped_not_prefixed() {
        assert_eq!(to_snake_case("2nd floor"), "nd_floor");
        assert_eq!(to_snake_case("67"), "");
    }

    /// Whatever comes out must be valid, or forcing the spelling has not forced anything.
    #[test]
    fn anything_non_empty_out_is_valid_in() {
        for s in [
            "Site 67",
            "---",
            "a",
            "ALLCAPS",
            "trailing___",
            "2nd floor",
            "m i x e d 99",
            "café",
        ] {
            let out = to_snake_case(s);
            if !out.is_empty() {
                assert!(is_snake_case(&out), "{s:?} produced invalid {out:?}");
            }
        }
    }

    /// Nothing survivable means no name — never a substituted one, because two maps both called
    /// `untitled` is the collision the naming rule exists to prevent.
    #[test]
    fn nothing_survivable_yields_no_name() {
        assert_eq!(to_snake_case(""), "");
        assert_eq!(to_snake_case("!!! ???"), "");
    }

    #[test]
    fn the_file_name_is_derived_from_the_name() {
        assert_eq!(map_file_name("site_67"), "site_67.map.ron");
    }
}

/// **Is this a usable descriptor id?** One or more snake_case segments joined by `/`.
///
/// The separator is a *namespace*, not decoration: the site kit ships `site/wall_corner`, and the two
/// halves mean "which kit" and "which piece". Strict snake_case would reject all 45 of them, so the
/// rule is applied per segment.
///
/// Refused rather than repaired. [`to_snake_case`] exists for the moment an id is *authored* — the
/// importer runs it on the file stem and the editor's field forces it as you type — and by the time a
/// library is being read, an id that is not a name is a fact about the file, not an input to fix.
pub fn is_id(s: &str) -> bool {
    !s.is_empty() && s.split('/').all(is_id_segment)
}

fn is_id_segment(s: &str) -> bool {
    s.starts_with(|c: char| c.is_ascii_lowercase())
        && !s.ends_with('_')
        && !s.contains("__")
        && s.chars().all(|c: char| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
}

/// **Force a string into the one spelling an id is allowed.**
///
/// [`to_snake_case`] is the wrong tool here, and using it is a bug that reads as a feature. An id's
/// `/` is a **namespace** — *which kit* and *which piece* — and `to_snake_case` drops every
/// character that is not a letter or a digit, so `site/wall_corner` comes out as
/// `site_wall_corner`: a different id, in a different namespace, that collides with nothing and
/// means nothing.
///
/// **So the rule is applied per segment**, exactly as [`is_id`] reads it: each `/`-separated part is
/// forced with [`to_snake_case`], and the separators are kept.
///
/// An empty part is **dropped**, not kept: `site//wall` and `/wall` are not two ids, they are one
/// id with a stray separator, and a stray separator is the author's typo rather than a name.
///
/// Returns an empty string when nothing survives, which callers must treat as *"no name yet"* —
/// never as a substitute, because two compositions called `untitled` is the collision
/// [`is_id`] exists to prevent.
pub fn to_id(s: &str) -> String {
    s.split('/')
        .filter(|segment| !segment.is_empty())
        .map(to_snake_case)
        .filter(|segment| !segment.is_empty())
        .collect::<Vec<_>>()
        .join("/")
}

#[cfg(test)]
mod id_tests {
    use super::*;

    /// Every id the shipped libraries carry has to keep loading, and the kit namespace is the reason
    /// this is per-segment rather than a single regex over the whole string.
    #[test]
    fn a_kit_namespaced_id_is_a_valid_id() {
        for ok in ["lamp_tall", "site/wall_corner", "crt_a", "barrel_acid_b", "tv", "is"] {
            assert!(is_id(ok), "`{ok}` should be a valid id");
        }
    }

    /// The shapes that mean somebody hand-edited a file or a tool wrote a display name into an id.
    #[test]
    fn a_name_that_is_not_snake_case_is_refused() {
        for bad in ["", "Wall Corner", "siteWall", "_leading", "trailing_", "double__bar", "9lives", "site//wall", "/wall"] {
            assert!(!is_id(bad), "`{bad}` should not be a valid id");
        }
    }

    /// **The namespace survives.** This is the whole reason `to_id` exists: `to_snake_case` on
    /// `site/wall_corner` gives `site_wall_corner`, which is a *different id* — a different kit, a
    /// different piece — and it collides with nothing, so nothing would ever say it was wrong.
    #[test]
    fn the_namespace_survives_the_forcing() {
        assert_eq!(to_id("site/wall_corner"), "site/wall_corner");
        assert_eq!(to_id("town/house_cottage_plaster"), "town/house_cottage_plaster");
        // **And the spelling is still forced**, per segment.
        assert_eq!(to_id("Town/House Cottage Plaster"), "town/house_cottage_plaster");
        assert_eq!(to_id("Site/Wall-Corner"), "site/wall_corner");
    }

    /// A stray separator is a typo, not a name — it is dropped rather than kept.
    #[test]
    fn a_stray_separator_is_dropped() {
        assert_eq!(to_id("site//wall"), "site/wall");
        assert_eq!(to_id("/wall"), "wall");
        assert_eq!(to_id("site/"), "site");
    }

    /// Whatever comes out must be a legal id, or the forcing has not forced anything.
    #[test]
    fn anything_non_empty_out_is_a_valid_id() {
        for s in [
            "site/wall",
            "Site 67",
            "---",
            "a",
            "ALLCAPS",
            "trailing___",
            "2nd floor",
            "site//wall",
            "/wall",
            "site/",
        ] {
            let out = to_id(s);
            if !out.is_empty() {
                assert!(is_id(&out), "{s:?} produced invalid {out:?}");
            }
        }
    }

    /// Nothing survivable means no id — never a substitute, because two compositions sharing a name
    /// is the collision `is_id` exists to prevent.
    #[test]
    fn nothing_survivable_yields_no_id() {
        assert_eq!(to_id(""), "");
        assert_eq!(to_id("!!! ???"), "");
    }
}