Functional Weave
Code in Python

text.normalise-name@1.0.0

impl/rust.rs

4,569 bytes · the Rust implementation · view raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

use super::funejson::Value;  ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one

/// Characters after which a new capital starts: hyphen, both apostrophes, full stop.
const PART_SEPARATORS: [char; 4] = ['-', '\'', '\u{2019}', '.'];

/// Lower-cased in the middle of a name: "Ludwig van Beethoven".
const PARTICLES: [&str; 20] = [
    "van", "von", "de", "da", "di", "del", "della", "der", "den", "des", "du",
    "la", "le", "ter", "ten", "dos", "das", "do", "bin", "ibn",
];

/// Names where "mac" is not a Gaelic prefix, so no capital follows it.
const NOT_MAC: [&str; 14] = [
    "macari", "macaulay", "macedo", "macey", "machado", "machell", "machen",
    "machin", "macias", "mackay", "mackey", "mackie", "mackin", "macklin",
];

/// The Unicode White_Space property, spelled out so all three languages agree.
fn is_whitespace(ch: char) -> bool {
    let cp = ch as u32;
    (0x09..=0x0d).contains(&cp)
        || cp == 0x20
        || cp == 0x85
        || cp == 0xa0
        || cp == 0x1680
        || (0x2000..=0x200a).contains(&cp)
        || cp == 0x2028
        || cp == 0x2029
        || cp == 0x202f
        || cp == 0x205f
        || cp == 0x3000
}

/// Case-map one character, but only when the mapping is itself one character.
/// 'ß' upper-cases to "SS"; leaving such characters alone keeps the three
/// languages identical.
fn lower_one(ch: char) -> char {
    let mapped: Vec<char> = ch.to_lowercase().collect();
    if mapped.len() == 1 {
        mapped[0]
    } else {
        ch
    }
}

fn upper_one(ch: char) -> char {
    let mapped: Vec<char> = ch.to_uppercase().collect();
    if mapped.len() == 1 {
        mapped[0]
    } else {
        ch
    }
}

fn is_mixed_case(word: &[char]) -> bool {
    let upper = word.iter().any(|&c| lower_one(c) != c);
    let lower = word.iter().any(|&c| upper_one(c) != c);
    upper && lower
}

fn capitalise(chars: &[char]) -> String {
    match chars.split_first() {
        None => String::new(),
        Some((first, rest)) => {
            let mut out = String::new();
            out.push(upper_one(*first));
            out.extend(rest.iter());
            out
        }
    }
}

/// Title-case one hyphen/apostrophe-delimited part, with the Mc and Mac rules.
fn case_part(part: &[char]) -> String {
    let lower: Vec<char> = part.iter().map(|&c| lower_one(c)).collect();
    let text: String = lower.iter().collect();
    if lower.len() >= 3 && text.starts_with("mc") {
        return format!("Mc{}", capitalise(&lower[2..]));
    }
    if lower.len() >= 6 && text.starts_with("mac") && !NOT_MAC.contains(&text.as_str()) {
        return format!("Mac{}", capitalise(&lower[3..]));
    }
    capitalise(&lower)
}

fn title_word(word: &[char]) -> String {
    let mut out = String::new();
    let mut part: Vec<char> = Vec::new();
    for &ch in word {
        if PART_SEPARATORS.contains(&ch) {
            out.push_str(&case_part(&part));
            out.push(ch);
            part.clear();
        } else {
            part.push(ch);
        }
    }
    out.push_str(&case_part(&part));
    out
}

/// Tidy a personal name: trim, collapse whitespace, and title-case words typed
/// all in one case, with the Mc, Mac, O', hyphen and particle rules.
pub fn normalise_name(value: &str) -> String {
    let mut words: Vec<Vec<char>> = Vec::new();
    let mut current: Vec<char> = Vec::new();
    for ch in value.chars() {
        if is_whitespace(ch) {
            if !current.is_empty() {
                words.push(std::mem::take(&mut current));
            }
        } else {
            current.push(ch);
        }
    }
    if !current.is_empty() {
        words.push(current);
    }

    let count = words.len();
    let out: Vec<String> = words
        .iter()
        .enumerate()
        .map(|(i, word)| {
            // Someone who typed their own capitals knows better than any rule.
            if is_mixed_case(word) {
                return word.iter().collect();
            }
            let lower: String = word.iter().map(|&c| lower_one(c)).collect();
            if i > 0 && i + 1 < count && PARTICLES.contains(&lower.as_str()) {
                return lower;
            }
            title_word(word)
        })
        .collect();
    out.join(" ")
}

pub fn fune_vector(args: &[Value]) -> Value {
    // Refuse what the typed signature cannot hold, with the wording TypeScript
    // and Python use, rather than let the conversion below quietly change it.
    if !matches!(args[0], Value::Str(_)) {
        panic!("normaliseName needs a string, received {:?}", args[0]);
    }
    Value::Str(normalise_name(args[0].as_str()))
}