wikioxide 0.1.0

MediaWiki wikitext parser producing typed domain models (templates, links, categories, warnings) with template expansion — no HTML, no network
Documentation
//! Per-line apostrophe resolution — a port of MediaWiki's `Parser::doQuotes`.

use crate::engine::ast::{Inline, TextRange};

use super::scan::Token;

/// Working classification of one apostrophe run after length normalization.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum QuoteKind {
    /// `''` toggles italics.
    Italic,
    /// `'''` toggles bold.
    Bold,
    /// `'''''` toggles both (bold outside, italics inside).
    BoldItalic,
}

/// A normalized apostrophe run: `literal` leading apostrophes stay text, the rest act
/// as `kind`.
#[derive(Debug)]
struct QuoteRun {
    range: TextRange,
    literal: u32,
    kind: QuoteKind,
}

/// A token of the quote pass: a finished node or a still-unresolved apostrophe run.
enum WorkToken {
    Node(Inline),
    Quote(QuoteRun),
}

/// Kind of an open formatting frame during emission.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum FrameKind {
    Bold,
    Italic,
}

/// Resolves apostrophe runs into nested [`Inline::Bold`]/[`Inline::Italic`] spans.
pub(super) fn do_quotes(flat: &str, tokens: Vec<Token>) -> Vec<Inline> {
    let mut work: Vec<WorkToken> = tokens
        .into_iter()
        .map(|t| match t {
            Token::Node(n) => WorkToken::Node(n),
            Token::Quotes(range) => WorkToken::Quote(normalize(range)),
        })
        .collect();
    rebalance(flat, &mut work);
    emit(work)
}

/// Splits a run of `n >= 2` apostrophes into leading literals plus a quote kind
/// (2 → italics, 3 → bold, 4 → `'` + bold, 5 → bold-italics, more → literals + both).
fn normalize(range: TextRange) -> QuoteRun {
    let n = range.end - range.start;
    let (literal, kind) = match n {
        2 => (0, QuoteKind::Italic),
        3 => (0, QuoteKind::Bold),
        4 => (1, QuoteKind::Bold),
        5 => (0, QuoteKind::BoldItalic),
        _ => (n - 5, QuoteKind::BoldItalic),
    };
    QuoteRun {
        range,
        literal,
        kind,
    }
}

/// MediaWiki's odd-bold/odd-italic fixup: demotes one bold run to `'` + italics,
/// preferring the first bold preceded by a single-letter word, then the first preceded
/// by a multi-letter word, then the first preceded by a space. Links and markers count
/// as non-space, non-letter characters, so they land in the multi-letter bucket.
fn rebalance(flat: &str, work: &mut [WorkToken]) {
    let (mut bold, mut italic) = (0u32, 0u32);
    for tok in work.iter() {
        if let WorkToken::Quote(run) = tok {
            match run.kind {
                QuoteKind::Italic => italic += 1,
                QuoteKind::Bold => bold += 1,
                QuoteKind::BoldItalic => {
                    bold += 1;
                    italic += 1;
                }
            }
        }
    }
    if bold % 2 == 0 || italic % 2 == 0 {
        return;
    }

    let mut first_space = None;
    let mut first_single = None;
    let mut first_multi = None;
    let mut ordinal = 0usize; // index among quote runs only
    for (i, tok) in work.iter().enumerate() {
        let run = match tok {
            WorkToken::Quote(run) => run,
            WorkToken::Node(_) => continue,
        };
        if run.kind == QuoteKind::Bold {
            let (x1, x2) = preceding_chars(flat, work, i);
            match (x1, x2) {
                (None, _) => {}
                (Some(' '), _) => {
                    if first_space.is_none() {
                        first_space = Some(ordinal);
                    }
                }
                // a lone preceding character counts as a word like PHP's clamping
                // substr does, so `x2 == None` falls to the multi-letter arm
                (Some(_), Some(' ')) => {
                    first_single = Some(ordinal);
                }
                (Some(_), _) => {
                    if first_multi.is_none() {
                        first_multi = Some(ordinal);
                    }
                }
            }
            if first_single.is_some() {
                break;
            }
        }
        ordinal += 1;
    }

    if let Some(target) = first_single.or(first_multi).or(first_space) {
        let mut seen = 0usize;
        for tok in work.iter_mut() {
            if let WorkToken::Quote(run) = tok {
                if seen == target {
                    run.literal += 1;
                    run.kind = QuoteKind::Italic;
                    break;
                }
                seen += 1;
            }
        }
    }
}

/// The last two "visible" characters before run `i`: the run's own literal apostrophes
/// first, then preceding tokens. Non-text nodes read as placeholder non-space
/// characters, the way MediaWiki sees the raw `[[…]]` text.
fn preceding_chars(flat: &str, work: &[WorkToken], i: usize) -> (Option<char>, Option<char>) {
    const PLACEHOLDER: char = '\u{fffc}';
    let mut got: Vec<char> = Vec::with_capacity(2);
    if let WorkToken::Quote(run) = &work[i] {
        got.extend(std::iter::repeat_n('\'', run.literal.min(2) as usize));
    }
    let mut j = i;
    while got.len() < 2 && j > 0 {
        j -= 1;
        match &work[j] {
            WorkToken::Node(Inline::Text(r)) => {
                for c in r.slice(flat).chars().rev() {
                    got.push(c);
                    if got.len() == 2 {
                        break;
                    }
                }
            }
            // an earlier apostrophe run can only ever supply the second character,
            // because two runs are never adjacent in the token stream
            WorkToken::Quote(_) => got.push('\''),
            WorkToken::Node(_) => {
                got.push(PLACEHOLDER);
                if got.len() < 2 {
                    got.push(PLACEHOLDER);
                }
            }
        }
    }
    (got.first().copied(), got.get(1).copied())
}

/// Linear emission with (bold, italic) open-state; open spans auto-close at the end of
/// the line.
fn emit(work: Vec<WorkToken>) -> Vec<Inline> {
    let mut root: Vec<Inline> = Vec::new();
    let mut stack: Vec<(FrameKind, Vec<Inline>)> = Vec::new();
    for tok in work {
        match tok {
            WorkToken::Node(node) => push_node(&mut root, &mut stack, node),
            WorkToken::Quote(run) => {
                if run.literal > 0 {
                    let text = Inline::Text(TextRange::new(
                        run.range.start,
                        run.range.start + run.literal,
                    ));
                    push_node(&mut root, &mut stack, text);
                }
                apply_quote(&mut root, &mut stack, run.kind);
            }
        }
    }
    while let Some((kind, content)) = stack.pop() {
        let node = wrap(kind, content);
        push_node(&mut root, &mut stack, node);
    }
    root
}

/// Appends `node` to the innermost open frame (or the line root).
fn push_node(root: &mut Vec<Inline>, stack: &mut [(FrameKind, Vec<Inline>)], node: Inline) {
    match stack.last_mut() {
        Some((_, content)) => content.push(node),
        None => root.push(node),
    }
}

/// Wraps collected frame content in its span node.
fn wrap(kind: FrameKind, content: Vec<Inline>) -> Inline {
    match kind {
        FrameKind::Bold => Inline::Bold(content),
        FrameKind::Italic => Inline::Italic(content),
    }
}

/// Whether a frame of `kind` is currently open.
fn is_open(stack: &[(FrameKind, Vec<Inline>)], kind: FrameKind) -> bool {
    stack.iter().any(|(k, _)| *k == kind)
}

/// Applies one quote run's toggles to the frame stack.
fn apply_quote(root: &mut Vec<Inline>, stack: &mut Vec<(FrameKind, Vec<Inline>)>, kind: QuoteKind) {
    match kind {
        QuoteKind::Italic => toggle(root, stack, FrameKind::Italic),
        QuoteKind::Bold => toggle(root, stack, FrameKind::Bold),
        QuoteKind::BoldItalic => {
            let bold = is_open(stack, FrameKind::Bold);
            let italic = is_open(stack, FrameKind::Italic);
            match (bold, italic) {
                (false, false) => {
                    // MediaWiki order: italics nest inside bold
                    stack.push((FrameKind::Bold, Vec::new()));
                    stack.push((FrameKind::Italic, Vec::new()));
                }
                (true, true) => {
                    // close the innermost first so neither frame reopens
                    let top = stack.last().map(|(k, _)| *k);
                    if top == Some(FrameKind::Italic) {
                        close(root, stack, FrameKind::Italic);
                        close(root, stack, FrameKind::Bold);
                    } else {
                        close(root, stack, FrameKind::Bold);
                        close(root, stack, FrameKind::Italic);
                    }
                }
                (true, false) => {
                    close(root, stack, FrameKind::Bold);
                    stack.push((FrameKind::Italic, Vec::new()));
                }
                (false, true) => {
                    close(root, stack, FrameKind::Italic);
                    stack.push((FrameKind::Bold, Vec::new()));
                }
            }
        }
    }
}

/// Opens `kind` if closed, else closes it.
fn toggle(root: &mut Vec<Inline>, stack: &mut Vec<(FrameKind, Vec<Inline>)>, kind: FrameKind) {
    if is_open(stack, kind) {
        close(root, stack, kind);
    } else {
        stack.push((kind, Vec::new()));
    }
}

/// Closes the open `kind` frame. A frame opened above it is wrapped and reopened, so
/// `'''a''b'''c''` nests the way MediaWiki's `</i></b><i>` tag stream does.
fn close(root: &mut Vec<Inline>, stack: &mut Vec<(FrameKind, Vec<Inline>)>, kind: FrameKind) {
    let mut reopen = Vec::new();
    while let Some((k, content)) = stack.pop() {
        let node = wrap(k, content);
        push_node(root, stack, node);
        if k == kind {
            break;
        }
        reopen.push(k);
    }
    for k in reopen.into_iter().rev() {
        stack.push((k, Vec::new()));
    }
}

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

    #[test]
    fn simple_italic() {
        check("''i''", "I[Text(i)]");
    }

    #[test]
    fn simple_bold() {
        check("'''b'''", "B[Text(b)]");
    }

    #[test]
    fn bold_italic_nests_italic_inside_bold() {
        check("'''''bi'''''", "B[I[Text(bi)]]");
    }

    #[test]
    fn four_apostrophes_keep_one_literal() {
        check("''''x''''", "Text(') B[Text(x) Text(')]");
    }

    #[test]
    fn more_than_five_keep_the_surplus_literal() {
        check("'''''''x", "Text('') B[I[Text(x)]]");
    }

    #[test]
    fn single_apostrophe_is_plain_text() {
        check("don't", "Text(don't)");
    }

    #[test]
    fn line_end_auto_closes_with_multi_letter_demotion() {
        // `''a'''b`: bold and italics both odd; the bold is preceded by the one-letter
        // text "a" whose second look-back char is the italics run, so it demotes as a
        // multi-letter word: `<i>a'</i>b`.
        check("''a'''b", "I[Text(a) Text(')] Text(b)");
    }

    #[test]
    fn demotion_prefers_single_letter_words() {
        check(
            "plain l'''bold''italic",
            "Text(plain l) Text(') I[Text(bold)] Text(italic)",
        );
    }

    #[test]
    fn demotion_falls_back_to_a_space() {
        check("a '''b''c", "Text(a ) Text(') I[Text(b)] Text(c)");
    }

    #[test]
    fn single_letter_beats_an_earlier_multi_letter_candidate() {
        check(
            "w'''a l'''b''c'''d",
            "Text(w) B[Text(a l) Text(') I[Text(b)] Text(c)] Text(d)",
        );
    }

    #[test]
    fn no_candidate_leaves_the_imbalance_alone() {
        check("'''a''", "B[Text(a) I[]]");
    }

    #[test]
    fn four_run_demotes_via_its_own_literal_apostrophe() {
        check("''''a''", "Text('') I[Text(a)]");
    }

    #[test]
    fn link_before_bold_counts_as_a_multi_letter_word() {
        check("[[a]]'''b''", "Link(A) Text(') I[Text(b)]");
    }

    #[test]
    fn one_letter_after_a_link_is_still_multi_letter() {
        // `X` is outside the link trail, so it stays a one-letter text run whose
        // second look-back character is the link placeholder (non-space → multi)
        check("[[a]]X'''b''", "Link(A) Text(X) Text(') I[Text(b)]");
    }

    #[test]
    fn interleaved_close_reopens_the_inner_span() {
        check("'''a''b'''c''", "B[Text(a) I[Text(b)]] I[Text(c)]");
    }

    #[test]
    fn bold_italic_closes_italic_then_continues_bold() {
        check("'''''a''b", "B[I[Text(a)] Text(b)]");
    }

    #[test]
    fn bold_italic_swaps_open_italic_for_bold() {
        check("''a'''''b", "I[Text(a)] B[Text(b)]");
    }

    #[test]
    fn bold_italic_swaps_open_bold_for_italic() {
        check("'''a'''''b", "B[Text(a)] I[Text(b)]");
    }

    #[test]
    fn bold_italic_closes_both_in_stack_order() {
        check("'''''a'''''", "B[I[Text(a)]]");
        check("''x'''y'''''", "I[Text(x) B[Text(y)]]");
    }
}