humanize-string 0.1.0

Convert a camelCase / snake_case / dash-case string into a human-readable one (fooBar -> Foo bar). A faithful port of the humanize-string npm package.
Documentation
//! # humanize-string — make a machine string human-readable
//!
//! Convert a `camelCase`, `snake_case`, or `dash-case` string into a readable phrase:
//! `fooBar` → `Foo bar`, `background-color` → `Background color`,
//! `XMLHttpRequest` → `Xml http request`. A faithful Rust port of the
//! [`humanize-string`](https://www.npmjs.com/package/humanize-string) npm package.
//!
//! ```
//! use humanize_string::humanize_string;
//!
//! assert_eq!(humanize_string("fooBar"), "Foo bar");
//! assert_eq!(humanize_string("background-color"), "Background color");
//! assert_eq!(humanize_string("HELLO_WORLD"), "Hello world");
//! ```
//!
//! Pass `preserve_case = true` to skip the camelCase splitting and keep the original casing
//! (only whitespace is normalized and the first letter capitalized):
//!
//! ```
//! use humanize_string::humanize_string_with;
//! assert_eq!(humanize_string_with("fooBar", true), "FooBar");
//! assert_eq!(humanize_string_with("HELLO_WORLD", true), "HELLO WORLD");
//! ```

#![forbid(unsafe_code)]
#![doc(html_root_url = "https://docs.rs/humanize-string/0.1.0")]

use decamelize::decamelize;

// Compile-test the README's examples as part of `cargo test`.
#[cfg(doctest)]
#[doc = include_str!("../README.md")]
struct ReadmeDoctests;

/// Make `string` human-readable: split camelCase, replace `_`/`-` with spaces, collapse
/// whitespace, and capitalize the first letter.
///
/// Equivalent to [`humanize_string_with(string, false)`](humanize_string_with).
///
/// ```
/// # use humanize_string::humanize_string;
/// assert_eq!(humanize_string("innerHTML"), "Inner html");
/// ```
#[must_use]
pub fn humanize_string(string: &str) -> String {
    humanize_string_with(string, false)
}

/// Make `string` human-readable, optionally preserving the original casing.
///
/// When `preserve_case` is `false` (the default in [`humanize_string`]), the input is first
/// de-camelcased and lower-cased. When `true`, the casing is kept as-is and only whitespace
/// normalization and the leading capitalization are applied.
///
/// ```
/// # use humanize_string::humanize_string_with;
/// assert_eq!(humanize_string_with("my_url_string", false), "My url string");
/// ```
#[must_use]
pub fn humanize_string_with(string: &str, preserve_case: bool) -> String {
    let mut s = if preserve_case {
        string.to_string()
    } else {
        decamelize(string).to_lowercase()
    };

    s = replace_underscore_dash(&s);
    s = collapse_whitespace(&s);
    let s = js_trim(&s);
    capitalize_first(s)
}

/// `/[_-]+/g → " "` — replace each run of underscores/dashes with a single space.
fn replace_underscore_dash(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    let mut in_run = false;
    for c in s.chars() {
        if c == '_' || c == '-' {
            if !in_run {
                out.push(' ');
                in_run = true;
            }
        } else {
            out.push(c);
            in_run = false;
        }
    }
    out
}

/// `/\s{2,}/g → " "` — collapse each run of two-or-more whitespace into a single space
/// (single whitespace characters are left unchanged, as in the reference).
fn collapse_whitespace(s: &str) -> String {
    let chars: Vec<char> = s.chars().collect();
    let mut out = String::with_capacity(s.len());
    let mut i = 0;
    while i < chars.len() {
        if is_js_whitespace(chars[i]) {
            let start = i;
            while i < chars.len() && is_js_whitespace(chars[i]) {
                i += 1;
            }
            if i - start >= 2 {
                out.push(' ');
            } else {
                out.push(chars[start]);
            }
        } else {
            out.push(chars[i]);
            i += 1;
        }
    }
    out
}

/// `String.prototype.trim()` — trim JavaScript whitespace from both ends.
fn js_trim(s: &str) -> &str {
    s.trim_matches(is_js_whitespace)
}

/// `charAt(0).toUpperCase() + slice(1)` — uppercase the first character.
fn capitalize_first(s: &str) -> String {
    let mut chars = s.chars();
    match chars.next() {
        None => String::new(),
        Some(first) => first.to_uppercase().chain(chars).collect(),
    }
}

/// The set of characters matched by JavaScript's `\s` (and trimmed by `trim`).
fn is_js_whitespace(c: char) -> bool {
    matches!(
        c,
        '\u{0009}'
            | '\u{000A}'
            | '\u{000B}'
            | '\u{000C}'
            | '\u{000D}'
            | '\u{0020}'
            | '\u{00A0}'
            | '\u{1680}'
            | '\u{2000}'
            ..='\u{200A}'
                | '\u{2028}'
                | '\u{2029}'
                | '\u{202F}'
                | '\u{205F}'
                | '\u{3000}'
                | '\u{FEFF}'
    )
}

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

    #[test]
    fn default_mode() {
        assert_eq!(humanize_string("fooBar"), "Foo bar");
        assert_eq!(humanize_string("background-color"), "Background color");
        assert_eq!(humanize_string("_id"), "Id");
        assert_eq!(humanize_string("fooBar123"), "Foo bar123");
        assert_eq!(humanize_string("BowmoreIslay"), "Bowmore islay");
        assert_eq!(humanize_string("XMLHttpRequest"), "Xml http request");
        assert_eq!(humanize_string("my_url_string"), "My url string");
        assert_eq!(humanize_string("HELLO_WORLD"), "Hello world");
        assert_eq!(
            humanize_string("backgroundColorURL"),
            "Background color url"
        );
    }

    #[test]
    fn preserve_case() {
        assert_eq!(humanize_string_with("fooBar", true), "FooBar");
        assert_eq!(humanize_string_with("HELLO_WORLD", true), "HELLO WORLD");
        assert_eq!(humanize_string_with("my-url", true), "My url");
    }

    #[test]
    fn whitespace_and_sharp_s() {
        assert_eq!(humanize_string("  spaced  out  "), "Spaced out");
        assert_eq!(humanize_string("a_b-c__d"), "A b c d");
        assert_eq!(humanize_string("ßeta"), "SSeta");
    }

    #[test]
    fn edge_cases() {
        assert_eq!(humanize_string(""), "");
        assert_eq!(humanize_string("a"), "A");
        assert_eq!(humanize_string("UPPERCASE"), "Uppercase");
        assert_eq!(humanize_string("___"), "");
    }
}