wikioxide 0.1.0

MediaWiki wikitext parser producing typed domain models (templates, links, categories, warnings) with template expansion — no HTML, no network
Documentation
//! String parser functions: `{{lc:}}`, `{{uc:}}`, `{{lcfirst:}}`, `{{ucfirst:}}`,
//! `{{padleft:}}`, `{{padright:}}`, `{{urlencode:}}`, `{{anchorencode:}}`. All pure.

/// Which flavor of URL encoding `{{urlencode:}}` applies.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum UrlEncodeKind {
    /// `QUERY` (the default): spaces become `+`.
    Query,
    /// `WIKI`: spaces become `_`.
    Wiki,
    /// `PATH`: spaces become `%20`.
    Path,
}

impl UrlEncodeKind {
    /// Parses the second argument of `{{urlencode:}}` (case-insensitive); anything else
    /// falls back to the `QUERY` default.
    pub(crate) fn parse(arg: &str) -> Self {
        match arg.trim().to_ascii_uppercase().as_str() {
            "WIKI" => UrlEncodeKind::Wiki,
            "PATH" => UrlEncodeKind::Path,
            _ => UrlEncodeKind::Query,
        }
    }
}

/// `{{lc:}}` — full Unicode lowercase.
pub(crate) fn lc(s: &str) -> String {
    s.to_lowercase()
}

/// `{{uc:}}` — full Unicode uppercase.
pub(crate) fn uc(s: &str) -> String {
    s.to_uppercase()
}

/// `{{lcfirst:}}` — lowercase the first character only.
pub(crate) fn lcfirst(s: &str) -> String {
    map_first(s, char::to_lowercase)
}

/// `{{ucfirst:}}` — uppercase the first character only.
pub(crate) fn ucfirst(s: &str) -> String {
    map_first(s, char::to_uppercase)
}

fn map_first<I: Iterator<Item = char>>(s: &str, f: impl FnOnce(char) -> I) -> String {
    let mut chars = s.chars();
    match chars.next() {
        None => String::new(),
        Some(first) => {
            let mut out = String::with_capacity(s.len());
            out.extend(f(first));
            out.push_str(chars.as_str());
            out
        }
    }
}

/// `{{padleft:s|width|pad}}` — left-pad `s` with `pad` to `width` characters. `pad`
/// defaults to `"0"`; an empty `pad`, or a `width` no larger than `s`, returns `s`
/// unchanged. The final pad segment is truncated so the result is exactly `width` wide.
pub(crate) fn padleft(s: &str, width: usize, pad: &str) -> String {
    match pad_prefix(s, width, pad) {
        None => s.to_owned(),
        Some(prefix) => format!("{prefix}{s}"),
    }
}

/// `{{padright:s|width|pad}}` — as [`padleft`] but padding is appended.
pub(crate) fn padright(s: &str, width: usize, pad: &str) -> String {
    match pad_prefix(s, width, pad) {
        None => s.to_owned(),
        Some(suffix) => format!("{s}{suffix}"),
    }
}

/// The pad segment needed to bring `s` up to `width` characters, or `None` when no
/// padding applies (already wide enough, or empty pad).
fn pad_prefix(s: &str, width: usize, pad: &str) -> Option<String> {
    let len = s.chars().count();
    if len >= width || pad.is_empty() {
        return None;
    }
    let needed = width - len;
    Some(pad.chars().cycle().take(needed).collect())
}

/// `{{urlencode:s|kind}}` — percent-encode `s`, with spaces handled per `kind`.
/// Unreserved characters (`A-Za-z0-9-_.`) pass through; everything else becomes
/// uppercase `%XX` of its UTF-8 bytes.
pub(crate) fn urlencode(s: &str, kind: UrlEncodeKind) -> String {
    let mut out = String::with_capacity(s.len());
    for c in s.chars() {
        match c {
            ' ' => match kind {
                UrlEncodeKind::Query => out.push('+'),
                UrlEncodeKind::Wiki => out.push('_'),
                UrlEncodeKind::Path => out.push_str("%20"),
            },
            'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' => out.push(c),
            other => percent_encode(&mut out, other),
        }
    }
    out
}

/// `{{anchorencode:s}}` — encode `s` as a section anchor: spaces (and underscores) become
/// `_`, and characters unsafe in an id/fragment are percent-encoded. `:` and `.` are kept
/// (they are legal, common in anchors).
pub(crate) fn anchorencode(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    for c in s.chars() {
        match c {
            ' ' | '_' => out.push('_'),
            'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '.' | ':' => out.push(c),
            other => percent_encode(&mut out, other),
        }
    }
    out
}

fn percent_encode(out: &mut String, c: char) {
    let mut buf = [0u8; 4];
    for byte in c.encode_utf8(&mut buf).bytes() {
        out.push('%');
        out.push(hex_digit(byte >> 4));
        out.push(hex_digit(byte & 0xf));
    }
}

fn hex_digit(nibble: u8) -> char {
    match nibble {
        0..=9 => (b'0' + nibble) as char,
        _ => (b'A' + nibble - 10) as char,
    }
}

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

    #[test]
    fn case_functions_cover_ascii_and_unicode() {
        assert_eq!(lc("HeLLo Мир"), "hello мир");
        assert_eq!(uc("HeLLo Мир"), "HELLO МИР");
        assert_eq!(lcfirst("Hello"), "hello");
        assert_eq!(ucfirst("hello"), "Hello");
        assert_eq!(ucfirst("привет"), "Привет");
        assert_eq!(lcfirst(""), "");
        assert_eq!(ucfirst(""), "");
        // ß uppercases to two chars — first-char mapping must handle expansion.
        assert_eq!(ucfirst("ßtraße"), "SStraße");
    }

    #[test]
    fn padleft_and_padright_pad_truncate_and_no_op() {
        assert_eq!(padleft("7", 3, "0"), "007");
        assert_eq!(padright("7", 3, "0"), "700");
        assert_eq!(padleft("xyz", 3, "0"), "xyz", "already wide enough");
        assert_eq!(padleft("xyz", 2, "0"), "xyz", "narrower than string");
        assert_eq!(padright("xyz", 2, "0"), "xyz", "padright no-op path");
        assert_eq!(padright("x", 5, ""), "x", "padright empty pad is a no-op");
        assert_eq!(padleft("x", 5, "ab"), "ababx", "multi-char pad repeats");
        assert_eq!(padright("x", 4, "ab"), "xaba", "final pad truncated");
        assert_eq!(padleft("x", 5, ""), "x", "empty pad is a no-op, not a hang");
        assert_eq!(padleft("мир", 5, "0"), "00мир", "width counts characters");
    }

    #[test]
    fn urlencode_handles_each_space_flavor_and_reserved_chars() {
        assert_eq!(urlencode("a b&c", UrlEncodeKind::Query), "a+b%26c");
        assert_eq!(urlencode("a b&c", UrlEncodeKind::Wiki), "a_b%26c");
        assert_eq!(urlencode("a b&c", UrlEncodeKind::Path), "a%20b%26c");
        assert_eq!(urlencode("keep-_.", UrlEncodeKind::Query), "keep-_.");
        assert_eq!(urlencode("é", UrlEncodeKind::Query), "%C3%A9");
    }

    #[test]
    fn url_encode_kind_parses_case_insensitively_with_query_default() {
        assert_eq!(UrlEncodeKind::parse("wiki"), UrlEncodeKind::Wiki);
        assert_eq!(UrlEncodeKind::parse(" PATH "), UrlEncodeKind::Path);
        assert_eq!(UrlEncodeKind::parse("query"), UrlEncodeKind::Query);
        assert_eq!(UrlEncodeKind::parse("nonsense"), UrlEncodeKind::Query);
    }

    #[test]
    fn anchorencode_keeps_anchor_safe_chars() {
        assert_eq!(anchorencode("Section one"), "Section_one");
        assert_eq!(anchorencode("a_b"), "a_b");
        assert_eq!(anchorencode("Note:1.2"), "Note:1.2");
        assert_eq!(anchorencode("x&y"), "x%26y");
    }
}