wikioxide 0.1.0

MediaWiki wikitext parser producing typed domain models (templates, links, categories, warnings) with template expansion — no HTML, no network
Documentation
//! User-link scanning: finds the `[[User:…]]` / `[[Участник:…]]` links that a saved
//! signature leaves in the wikitext (MediaWiki substitutes `~~~~` at save time, so the
//! links are literal source, not templates), and resolves each to its [`Author`]. Anonymous
//! signatures link to `Special:Contributions/<ip>`.

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

/// The person who signed a comment.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[non_exhaustive]
pub struct Author {
    name: String,
    anonymous: bool,
}

impl Author {
    /// The account name (spaces, not underscores) or, for an anonymous editor, the IP.
    #[must_use]
    pub fn name(&self) -> &str {
        &self.name
    }

    /// Whether the signature was an anonymous `Special:Contributions/<ip>` link.
    #[must_use]
    pub fn is_anonymous(&self) -> bool {
        self.anonymous
    }
}

/// One user link located in the source: its byte range and the author it names.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct UserLink {
    /// Byte offset of the opening `[[`.
    pub(crate) start: usize,
    /// Byte offset just past the closing `]]`.
    pub(crate) end: usize,
    pub(crate) author: Author,
}

/// Every user link in `text`, in source order.
pub(crate) fn find_user_links(text: &str, config: &SiteConfig) -> Vec<UserLink> {
    let bytes = text.as_bytes();
    let mut out = Vec::new();
    let mut i = 0;
    while i + 1 < bytes.len() {
        if bytes[i] == b'[' && bytes[i + 1] == b'[' {
            if let Some(link) = parse_link(text, i, config) {
                i = link.end;
                out.push(link);
                continue;
            }
        }
        i += 1;
    }
    out
}

/// Parses one `[[…]]` starting at `start` and, when it targets a user, returns the link.
fn parse_link(text: &str, start: usize, config: &SiteConfig) -> Option<UserLink> {
    let rest = &text[start + 2..];
    let close = rest.find("]]")?;
    let inner = &rest[..close];
    let target = inner.split('|').next().unwrap_or(inner);
    let target = target.split('#').next().unwrap_or(target).trim();
    let title = Title::parse(target, config).ok()?;
    let author = author_of(&title)?;
    Some(UserLink {
        start,
        end: start + 2 + close + 2,
        author,
    })
}

/// Resolves a link target to an author, or `None` when it is not a user link. `User:` (id 2)
/// and `User talk:` (id 3) name a registered editor; `Special:Contributions/<ip>` (id -1) an
/// anonymous one. Subpages (`User:X/Archive`) resolve to the base account `X`.
fn author_of(title: &Title) -> Option<Author> {
    match title.namespace().id() {
        2 | 3 => {
            let name = base_segment(title.dbkey());
            (!name.is_empty()).then_some(Author {
                name,
                anonymous: false,
            })
        }
        -1 => {
            let (page, ip) = title.dbkey().split_once('/')?;
            is_contributions(page).then(|| Author {
                name: ip.replace('_', " "),
                anonymous: true,
            })
        }
        _ => None,
    }
}

/// The base account name of a (possibly sub-paged) dbkey, underscores restored to spaces.
fn base_segment(dbkey: &str) -> String {
    dbkey.split('/').next().unwrap_or(dbkey).replace('_', " ")
}

/// Whether a `Special:` subpage names the contributions list (localized on ru wikis).
fn is_contributions(page: &str) -> bool {
    page == "Contributions" || page == "Вклад"
}

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

    fn config() -> SiteConfig {
        SiteConfig::builder("ru", "Википедия")
            .namespace(NamespaceDef::main())
            .namespace(NamespaceDef::new(
                2,
                "User",
                "Участник",
                ["У", "Участница"],
                true,
            ))
            .namespace(NamespaceDef::new(
                3,
                "User talk",
                "Обсуждение участника",
                ["ОУ"],
                true,
            ))
            .namespace(NamespaceDef::new(
                -1,
                "Special",
                "Служебная",
                Vec::<String>::new(),
                true,
            ))
            .build()
            .unwrap()
    }

    fn names(text: &str) -> Vec<(String, bool)> {
        find_user_links(text, &config())
            .into_iter()
            .map(|l| (l.author.name().to_owned(), l.author.is_anonymous()))
            .collect()
    }

    #[test]
    fn finds_registered_user_and_talk_links() {
        let text = "[[User:John Doe|John]] and [[Обсуждение участника:Иван|обс]]";
        assert_eq!(
            names(text),
            [("John Doe".to_owned(), false), ("Иван".to_owned(), false),]
        );
    }

    #[test]
    fn localized_and_aliased_prefixes_resolve() {
        assert_eq!(names("[[Участник:Пётр]]"), [("Пётр".to_owned(), false)]);
        assert_eq!(names("[[У:Пётр|П]]"), [("Пётр".to_owned(), false)]);
    }

    #[test]
    fn subpages_resolve_to_the_base_account() {
        assert_eq!(
            names("[[User:Alice/Archive 1|link]]"),
            [("Alice".to_owned(), false)]
        );
    }

    #[test]
    fn anonymous_contributions_link_is_an_anon_author() {
        let text = "[[Служебная:Вклад/192.0.2.1|192.0.2.1]]";
        assert_eq!(names(text), [("192.0.2.1".to_owned(), true)]);
    }

    #[test]
    fn spans_cover_the_whole_link() {
        let text = "x [[User:Bob|Bob]] y";
        let links = find_user_links(text, &config());
        assert_eq!(links.len(), 1);
        assert_eq!(&text[links[0].start..links[0].end], "[[User:Bob|Bob]]");
    }

    #[test]
    fn non_user_links_and_malformed_brackets_are_ignored() {
        assert!(names("[[Article]] and [[File:X.png|thumb]]").is_empty());
        assert!(names("[[Служебная:Log/x|l]]").is_empty());
        assert!(names("[[User:").is_empty());
        assert!(names("[[User:|empty]]").is_empty());
    }
}