wikioxide 0.1.0

MediaWiki wikitext parser producing typed domain models (templates, links, categories, warnings) with template expansion — no HTML, no network
Documentation
//! Redirect detection: recognizes `#REDIRECT [[Target#fragment]]`-style pages using
//! the wiki's localized redirect keywords.

use crate::config::SiteConfig;
use crate::model::Redirect;
use crate::title::Title;

/// Detects a redirect declaration at the top of `text`: leading whitespace, one of
/// the configured redirect keywords (case-insensitive), optional whitespace, then a
/// `[[…]]` link. The target is everything up to the first `|` or the closing
/// brackets; a `#fragment` is split off (an empty fragment counts as none) and the
/// rest must parse as a valid title (main namespace by default). Anything after the
/// link is irrelevant; any deviation before it yields `None`.
pub(crate) fn detect(text: &str, config: &SiteConfig) -> Option<Redirect> {
    let after_keyword = strip_keyword(text.trim_start(), config)?;
    let rest = after_keyword.trim_start();
    let inner = rest.strip_prefix("[[")?;
    let inner = &inner[..inner.find("]]")?];
    let target = match inner.find('|') {
        Some(pipe) => &inner[..pipe],
        None => inner,
    };
    let (page, fragment) = match target.split_once('#') {
        Some((page, fragment)) if !fragment.is_empty() => (page, Some(fragment.to_owned())),
        Some((page, _)) => (page, None),
        None => (target, None),
    };
    let title = Title::parse(page, config).ok()?;
    Some(Redirect::new(title, fragment))
}

/// Strips the longest matching redirect keyword from the start of `text`.
/// [`SiteConfig::redirect_aliases`] arrive pre-folded (lower-cased, with the leading
/// `#`), so the candidate is lower-cased char by char for the comparison. Longest
/// match wins so that `#REDIRECT` is not cut short by a `#REDIR` alias.
fn strip_keyword<'a>(text: &'a str, config: &SiteConfig) -> Option<&'a str> {
    let mut longest: Option<usize> = None;
    for alias in config.redirect_aliases() {
        if let Some(len) = folded_prefix_len(text, alias) {
            longest = Some(longest.map_or(len, |best| best.max(len)));
        }
    }
    longest.map(|len| &text[len..])
}

/// Byte length of the prefix of `text` whose per-char lower-casing equals `folded`
/// exactly; `None` when `text` does not start with the folded keyword. (Keywords
/// contain no whitespace, so plain lower-casing matches the config's fold.)
fn folded_prefix_len(text: &str, folded: &str) -> Option<usize> {
    let mut expected = folded.chars();
    let mut len = 0_usize;
    for c in text.chars() {
        if expected.as_str().is_empty() {
            break;
        }
        for lower in c.to_lowercase() {
            if expected.next() != Some(lower) {
                return None;
            }
        }
        len += c.len_utf8();
    }
    if expected.as_str().is_empty() {
        Some(len)
    } else {
        None
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::config::{MagicWordDef, NamespaceDef};

    fn cfg() -> SiteConfig {
        SiteConfig::builder("ru", "Википедия")
            .namespace(NamespaceDef::main())
            .magic_word(MagicWordDef::new(
                "redirect",
                ["#REDIRECT", "#REDIR", "#перенаправление"],
                false,
            ))
            .build()
            .unwrap()
    }

    #[test]
    fn english_and_russian_keywords_detect() {
        let r = detect("#REDIRECT [[Цель]]", &cfg()).unwrap();
        assert_eq!(r.target().dbkey(), "Цель");
        assert_eq!(r.fragment(), None);

        let r = detect("#перенаправление [[Foo]]", &cfg()).unwrap();
        assert_eq!(r.target().dbkey(), "Foo");
    }

    #[test]
    fn matching_is_case_insensitive() {
        assert!(detect("#ReDiReCt [[X]]", &cfg()).is_some());
        assert!(detect("#ПЕРЕНАПРАВЛЕНИЕ [[X]]", &cfg()).is_some());
        assert!(detect("#redir [[X]]", &cfg()).is_some());
    }

    #[test]
    fn leading_blank_lines_and_glued_brackets_are_fine() {
        assert!(detect("\n\n   #redirect [[X]]", &cfg()).is_some());
        assert!(detect("#REDIRECT[[X]]", &cfg()).is_some());
    }

    #[test]
    fn longest_keyword_wins_over_its_prefix_alias() {
        // If `#REDIR` matched first, the leftover `ect` would break the link scan.
        let r = detect("#REDIRECT [[Цель]]", &cfg()).unwrap();
        assert_eq!(r.target().dbkey(), "Цель");
    }

    #[test]
    fn fragments_split_off_and_empty_fragments_drop() {
        let r = detect("#REDIRECT [[Страница#Раздел]]", &cfg()).unwrap();
        assert_eq!(r.target().dbkey(), "Страница");
        assert_eq!(r.fragment(), Some("Раздел"));

        let r = detect("#REDIRECT [[Страница#]]", &cfg()).unwrap();
        assert_eq!(r.fragment(), None);
    }

    #[test]
    fn pipe_labels_and_trailing_junk_are_ignored() {
        let r = detect("#REDIRECT [[Цель|метка]] {{R from move}}\nещё", &cfg()).unwrap();
        assert_eq!(r.target().dbkey(), "Цель");
    }

    #[test]
    fn keyword_without_a_link_is_no_redirect() {
        assert_eq!(detect("#REDIRECT", &cfg()), None);
        assert_eq!(detect("#REDIRECT   ", &cfg()), None);
        assert_eq!(detect("#REDIRECT to [[X]]", &cfg()), None);
        assert_eq!(detect("#REDIRECT [[X", &cfg()), None);
    }

    #[test]
    fn missing_or_wrong_keyword_is_no_redirect() {
        assert_eq!(detect("REDIRECT [[X]]", &cfg()), None);
        assert_eq!(detect("#GO [[X]]", &cfg()), None);
        assert_eq!(detect("#redirec [[X]]", &cfg()), None);
        assert_eq!(detect("#red", &cfg()), None);
        assert_eq!(detect("", &cfg()), None);
    }

    #[test]
    fn invalid_targets_are_no_redirect() {
        assert_eq!(detect("#REDIRECT [[<плохо]]", &cfg()), None);
        assert_eq!(detect("#REDIRECT [[]]", &cfg()), None);
        assert_eq!(detect("#REDIRECT [[#Фраг]]", &cfg()), None);
    }
}