wikioxide 0.1.0

MediaWiki wikitext parser producing typed domain models (templates, links, categories, warnings) with template expansion — no HTML, no network
Documentation
//! Comment segmentation and threading: splits a talk page at its signatures into signed
//! comments, attributes each to its author and timestamp, and threads them by leading
//! `:`/`*`/`#` indentation. Everything is reported as byte spans into the original wikitext.

use crate::config::SiteConfig;
use crate::model::Span;

use super::signature::{Author, UserLink, find_user_links};
use super::timestamp::{CommentTime, FoundTime, TimestampMatcher};

/// One signed comment on a talk page.
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[non_exhaustive]
pub struct Comment {
    author: Option<Author>,
    time: Option<CommentTime>,
    level: u8,
    span: Span,
    signature_span: Span,
    parent: Option<usize>,
}

impl Comment {
    /// The signer, when a user link precedes the timestamp on the same line.
    #[must_use]
    pub fn author(&self) -> Option<&Author> {
        self.author.as_ref()
    }

    /// The signature timestamp.
    #[must_use]
    pub fn time(&self) -> Option<CommentTime> {
        self.time
    }

    /// Indentation depth: the count of leading `:`/`*`/`#` markers on the comment's first
    /// line (0 for a top-level comment).
    #[must_use]
    pub fn level(&self) -> u8 {
        self.level
    }

    /// Source span of the comment: its first line through the end of its signature.
    #[must_use]
    pub fn span(&self) -> Span {
        self.span
    }

    /// Source span of the signature itself (the author link, when present, through the
    /// timestamp).
    #[must_use]
    pub fn signature_span(&self) -> Span {
        self.signature_span
    }

    /// Index, in [`Discussion::comments`], of the nearest less-indented comment this one
    /// replies to; `None` for a top-level comment.
    #[must_use]
    pub fn parent(&self) -> Option<usize> {
        self.parent
    }
}

/// The threaded comments of one talk page, in source order.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct Discussion {
    comments: Vec<Comment>,
}

impl Discussion {
    /// Parses the signed comments out of raw talk-page wikitext, using `config` for user
    /// namespaces and month names. On a saved page signatures are literal source (MediaWiki
    /// substitutes `~~~~` at save), so no template expansion is needed.
    #[must_use]
    pub fn from_wikitext(wikitext: &str, config: &SiteConfig) -> Discussion {
        Discussion {
            comments: build(wikitext, config),
        }
    }

    /// Every comment, in source order. A comment's [`parent`](Comment::parent) indexes back
    /// into this slice.
    #[must_use]
    pub fn comments(&self) -> &[Comment] {
        &self.comments
    }
}

fn build(text: &str, config: &SiteConfig) -> Vec<Comment> {
    let times = TimestampMatcher::new(config).find_all(text);
    let links = find_user_links(text, config);

    let mut comments = Vec::new();
    let mut seg_start = 0;
    for ft in &times {
        let author_link = last_link_before(&links, ft, text);
        let sig_start = author_link.map_or(ft.start, |l| l.start);
        let line_start = content_line_start(text, seg_start);
        comments.push(Comment {
            author: author_link.map(|l| l.author.clone()),
            time: Some(ft.time),
            level: indent_level(text, line_start),
            span: Span {
                start: line_start,
                end: ft.end,
            },
            signature_span: Span {
                start: sig_start,
                end: ft.end,
            },
            parent: None,
        });
        seg_start = ft.end;
    }
    assign_parents(&mut comments);
    comments
}

/// The user link closest before `ft` that sits on the same line as the timestamp — the
/// signer. Links on an earlier line (e.g. one quoted in the comment body) are ignored.
fn last_link_before<'l>(links: &'l [UserLink], ft: &FoundTime, text: &str) -> Option<&'l UserLink> {
    links
        .iter()
        .filter(|l| l.end <= ft.start)
        .rfind(|l| !text[l.end..ft.start].contains('\n'))
}

fn content_line_start(text: &str, from: usize) -> usize {
    let bytes = text.as_bytes();
    let mut i = from;
    while i < bytes.len() && matches!(bytes[i], b'\n' | b'\r' | b' ' | b'\t') {
        i += 1;
    }
    let mut ls = i.min(bytes.len());
    while ls > 0 && bytes[ls - 1] != b'\n' {
        ls -= 1;
    }
    ls
}

fn indent_level(text: &str, line_start: usize) -> u8 {
    let count = text.as_bytes()[line_start..]
        .iter()
        .take_while(|&&b| matches!(b, b':' | b'*' | b'#'))
        .count();
    u8::try_from(count).unwrap_or(u8::MAX)
}

/// Sets each comment's parent to the nearest preceding comment of a strictly smaller level.
fn assign_parents(comments: &mut [Comment]) {
    let mut stack: Vec<usize> = Vec::new();
    for i in 0..comments.len() {
        let level = comments[i].level;
        while stack
            .last()
            .is_some_and(|&top| comments[top].level >= level)
        {
            stack.pop();
        }
        comments[i].parent = stack.last().copied();
        stack.push(i);
    }
}

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

    fn config() -> SiteConfig {
        let genitive = [
            "января",
            "февраля",
            "марта",
            "апреля",
            "мая",
            "июня",
            "июля",
            "августа",
            "сентября",
            "октября",
            "ноября",
            "декабря",
        ]
        .map(String::from);
        SiteConfig::builder("ru", "Википедия")
            .namespace(NamespaceDef::main())
            .namespace(NamespaceDef::new(2, "User", "Участник", ["У"], true))
            .namespace(NamespaceDef::new(
                3,
                "User talk",
                "Обсуждение участника",
                ["ОУ"],
                true,
            ))
            .months(MonthNames {
                genitive: genitive.clone(),
                nominative: genitive.clone(),
                abbreviations: genitive,
            })
            .build()
            .unwrap()
    }

    fn parse(text: &str) -> Discussion {
        Discussion::from_wikitext(text, &config())
    }

    #[test]
    fn a_single_signed_comment_carries_author_time_and_span() {
        let text = "Предлагаю переименовать. [[У:Иван|Иван]] 08:26, 8 мая 2026 (UTC)";
        let d = parse(text);
        assert_eq!(d.comments().len(), 1);
        let c = &d.comments()[0];
        assert_eq!(c.author().map(Author::name), Some("Иван"));
        assert_eq!(c.time().map(|t| t.day()), Some(8));
        assert_eq!(c.level(), 0);
        assert_eq!(c.parent(), None);
        assert_eq!(&text[c.span().start..c.span().end], text);
        let sig = &text[c.signature_span().start..c.signature_span().end];
        assert_eq!(sig, "[[У:Иван|Иван]] 08:26, 8 мая 2026 (UTC)");
    }

    #[test]
    fn indentation_threads_replies_to_their_parents() {
        let text = concat!(
            "Номинирую. [[У:A|A]] 08:00, 1 мая 2026 (UTC)\n",
            ": Против. [[У:B|B]] 09:00, 1 мая 2026 (UTC)\n",
            ":: Почему? [[У:A|A]] 10:00, 1 мая 2026 (UTC)\n",
            ": Ещё реплика. [[У:C|C]] 11:00, 1 мая 2026 (UTC)\n",
        );
        let d = parse(text);
        let c = d.comments();
        assert_eq!(c.len(), 4);
        let levels: Vec<u8> = c.iter().map(Comment::level).collect();
        assert_eq!(levels, [0, 1, 2, 1]);
        assert_eq!(c[0].parent(), None);
        assert_eq!(c[1].parent(), Some(0), "reply to nomination");
        assert_eq!(c[2].parent(), Some(1), "reply to B");
        assert_eq!(
            c[3].parent(),
            Some(0),
            "back to top level → replies to nomination"
        );
        let authors: Vec<&str> = c
            .iter()
            .filter_map(|c| c.author().map(Author::name))
            .collect();
        assert_eq!(authors, ["A", "B", "A", "C"]);
    }

    #[test]
    fn a_timestamp_without_a_same_line_user_link_has_no_author() {
        // The user link is on an earlier line, so it does not sign this timestamp.
        let text = "[[У:X|X]] wrote something\nand later 08:00, 1 мая 2026 (UTC)";
        let d = parse(text);
        assert_eq!(d.comments().len(), 1);
        assert_eq!(d.comments()[0].author(), None);
        assert_eq!(d.comments()[0].time().map(|t| t.hour()), Some(8));
    }

    #[test]
    fn two_signatures_on_one_line_both_start_at_that_line() {
        let text = "[[У:A|A]] 08:00, 1 мая 2026 (UTC) and [[У:B|B]] 09:00, 1 мая 2026 (UTC)";
        let d = parse(text);
        assert_eq!(d.comments().len(), 2);
        // The second comment's content is mid-line, so its span backs up to the line start.
        assert_eq!(d.comments()[1].span().start, 0);
        assert_eq!(d.comments()[1].author().map(Author::name), Some("B"));
    }

    #[test]
    fn text_without_signatures_yields_no_comments() {
        assert!(parse("Просто текст без подписи.").comments().is_empty());
        assert_eq!(Discussion::default().comments().len(), 0);
    }
}