wikioxide 0.1.0

MediaWiki wikitext parser producing typed domain models (templates, links, categories, warnings) with template expansion — no HTML, no network
Documentation
//! Stage-S3 inline pass: parses one line of flattened text into [`Inline`] nodes.
//!
//! Tokenizing (internal/external links, HTML tags, entities, magic links, behavior
//! switches, strip markers) happens left to right in [`scan`]; apostrophe runs are then
//! resolved by the per-line [`quotes`] pass, mirroring MediaWiki's `doQuotes`.

mod html;
mod links;
mod quotes;
mod scan;

use crate::config::SiteConfig;
use crate::engine::ast::{Inline, TextRange};
use crate::warn::WarningCollector;

/// Nested labels/captions recurse at most this deep; anything deeper stays literal text.
const MAX_DEPTH: u8 = 8;

/// Parses one line (`line` must contain no `\n`) of the flattened text into inline
/// nodes. Strip markers are atoms; a stray `\x7F` that does not decode is literal text.
pub(crate) fn parse_inline(
    flat: &str,
    line: TextRange,
    config: &SiteConfig,
    warnings: &mut WarningCollector,
) -> Vec<Inline> {
    parse_at_depth(flat, line, config, warnings, 0)
}

/// Depth-capped worker behind [`parse_inline`]; link labels and image captions re-enter
/// here with `depth + 1` and fall back to literal text past [`MAX_DEPTH`].
fn parse_at_depth(
    flat: &str,
    range: TextRange,
    config: &SiteConfig,
    warnings: &mut WarningCollector,
    depth: u8,
) -> Vec<Inline> {
    if range.is_empty() {
        return Vec::new();
    }
    if depth > MAX_DEPTH {
        return vec![Inline::Text(range)];
    }
    let tokens = scan::scan(flat, range, config, warnings, depth);
    quotes::do_quotes(flat, tokens)
}

#[cfg(test)]
mod testutil {
    //! Shared helpers for the inline-parser test modules.

    use crate::config::{MagicLinks, MagicWordDef, NamespaceDef, SiteConfig, SiteConfigBuilder};
    use crate::engine::ast::{Inline, TextRange};
    use crate::warn::{Warning, WarningCollector};

    /// The common builder both test configurations share: ru-flavored namespaces with
    /// Cyrillic aliases, `fr`/`de` interwikis, a Cyrillic link trail and the magic words
    /// the task pins.
    fn builder() -> SiteConfigBuilder {
        SiteConfig::builder("ru", "Тест")
            .namespace(NamespaceDef::main())
            .namespace(NamespaceDef::new(10, "Template", "Шаблон", ["Ш"], true))
            .namespace(NamespaceDef::new(14, "Category", "Категория", ["К"], true))
            .namespace(NamespaceDef::new(
                6,
                "File",
                "Файл",
                ["Image", "Изображение"],
                true,
            ))
            .namespace(NamespaceDef::new(
                828,
                "Module",
                "Модуль",
                Vec::<String>::new(),
                true,
            ))
            .interwiki(["fr", "de"])
            .link_trail("a-zа-яё")
            .magic_word(MagicWordDef::new(
                "redirect",
                ["#REDIRECT", "#ПЕРЕНАПРАВЛЕНИЕ"],
                false,
            ))
            .magic_word(MagicWordDef::new("notoc", ["__NOTOC__"], true))
            .magic_word(MagicWordDef::new("toc", ["__TOC__"], true))
            .magic_word(MagicWordDef::new("!", ["!"], true))
            .magic_word(MagicWordDef::new("=", ["="], true))
    }

    /// The default test configuration (magic links on).
    pub(super) fn config() -> SiteConfig {
        builder().build().unwrap()
    }

    /// Same wiki with all RFC/PMID/ISBN magic links disabled.
    pub(super) fn config_no_magic() -> SiteConfig {
        builder()
            .magic_links(MagicLinks {
                rfc: false,
                pmid: false,
                isbn: false,
            })
            .build()
            .unwrap()
    }

    /// Parses `src` as one whole line with the default config.
    pub(super) fn parse(src: &str) -> (Vec<Inline>, Vec<Warning>) {
        parse_with(src, &config())
    }

    /// Parses `src` as one whole line with an explicit config.
    pub(super) fn parse_with(src: &str, config: &SiteConfig) -> (Vec<Inline>, Vec<Warning>) {
        let mut warnings = WarningCollector::new();
        let out = super::parse_inline(
            src,
            TextRange::new(0, src.len() as u32),
            config,
            &mut warnings,
        );
        (out, warnings.into_vec())
    }

    /// Parses `src` and asserts the compact dump matches with no warnings emitted.
    pub(super) fn check(src: &str, expected: &str) {
        let (nodes, warnings) = parse(src);
        assert_eq!(dump(src, &nodes), expected, "input: {src:?}");
        assert_eq!(warnings, Vec::new(), "input: {src:?}");
    }

    /// Serializes inline nodes into a compact single-line form for assertions.
    pub(super) fn dump(flat: &str, nodes: &[Inline]) -> String {
        let parts: Vec<String> = nodes.iter().map(|n| dump_one(flat, n)).collect();
        parts.join(" ")
    }

    /// Renders one node (test-only recursion over span/label trees).
    fn dump_one(flat: &str, node: &Inline) -> String {
        match node {
            Inline::Text(r) => format!("Text({})", r.slice(flat)),
            Inline::Entity { range, decoded } => {
                format!("Ent({}={decoded})", range.slice(flat))
            }
            Inline::Marker { id, kind } => format!("Marker({kind:?}#{})", id.0),
            Inline::Link {
                target,
                written: _,
                fragment,
                label,
                trail,
                pipe_trick,
            } => {
                // the inline parser never sets the (contract-reserved) pipe-trick flag
                assert!(!pipe_trick);
                let mut s = format!("Link({}", target.text());
                if let Some(f) = fragment {
                    s.push('#');
                    s.push_str(f);
                }
                if let Some(l) = label {
                    s.push('|');
                    s.push_str(&dump(flat, l));
                }
                if let Some(t) = trail {
                    s.push('+');
                    s.push_str(t.slice(flat));
                }
                s.push(')');
                s
            }
            Inline::Category { category, sortkey } => match sortkey {
                Some(k) => format!("Cat({}|{k})", category.text()),
                None => format!("Cat({})", category.text()),
            },
            Inline::InterLanguage {
                prefix,
                target,
                escaped,
            } => {
                let colon = if *escaped { ":" } else { "" };
                format!("Iw({colon}{prefix}:{target})")
            }
            Inline::Image {
                target,
                params,
                caption,
            } => {
                let ps: Vec<&str> = params.iter().map(|p| p.slice(flat)).collect();
                match caption {
                    Some(c) => {
                        format!(
                            "Img({}|[{}]|{})",
                            target.text(),
                            ps.join(","),
                            dump(flat, c)
                        )
                    }
                    None => format!("Img({}|[{}])", target.text(), ps.join(",")),
                }
            }
            Inline::ExternalLink { url, label } => match label {
                Some(l) => format!("Ext({url}|{})", dump(flat, l)),
                None => format!("Ext({url})"),
            },
            Inline::AutoUrl { url, range } => format!("Auto({url}={})", range.slice(flat)),
            Inline::MagicLink { kind, range } => format!("Magic({kind:?}:{})", range.slice(flat)),
            Inline::Italic(inner) => format!("I[{}]", dump(flat, inner)),
            Inline::Bold(inner) => format!("B[{}]", dump(flat, inner)),
            Inline::HtmlTag {
                name,
                closing,
                self_closing,
                attributes,
            } => {
                let mut s = format!("Html({name}");
                if *closing {
                    s.push_str("/c");
                }
                if *self_closing {
                    s.push_str("/s");
                }
                if let Some(a) = attributes {
                    s.push(' ');
                    s.push_str(a.slice(flat));
                }
                s.push(')');
                s
            }
            Inline::BehaviorSwitch { id, range } => format!("Sw({id}:{})", range.slice(flat)),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::testutil::{check, config, parse};
    use super::{MAX_DEPTH, parse_at_depth};
    use crate::engine::ast::{Inline, TextRange};
    use crate::warn::WarningCollector;

    #[test]
    fn empty_line_yields_no_nodes() {
        let (nodes, warnings) = parse("");
        assert_eq!(nodes, Vec::new());
        assert_eq!(warnings, Vec::new());
    }

    #[test]
    fn plain_text_is_one_node() {
        check("hello мир", "Text(hello мир)");
    }

    #[test]
    fn depth_cap_returns_literal_text() {
        let cfg = config();
        let mut warnings = WarningCollector::new();
        let src = "''x''";
        let out = parse_at_depth(
            src,
            TextRange::new(0, src.len() as u32),
            &cfg,
            &mut warnings,
            MAX_DEPTH + 1,
        );
        assert_eq!(out, vec![Inline::Text(TextRange::new(0, 5))]);
        assert_eq!(warnings.into_vec(), Vec::new());
    }

    #[test]
    fn deeply_nested_captions_go_literal_past_the_cap() {
        let mut src = String::new();
        for _ in 0..10 {
            src.push_str("[[File:A.jpg|");
        }
        src.push_str("deep");
        src.push_str(&"]]".repeat(10));
        let (nodes, warnings) = parse(&src);
        let dumped = super::testutil::dump(&src, &nodes);
        // The two innermost levels exceed MAX_DEPTH and stay literal caption text.
        assert!(dumped.contains("Text([[File:A.jpg|"), "dump: {dumped}");
        assert_eq!(warnings, Vec::new());
    }

    #[test]
    fn kitchen_sink_line() {
        check(
            "a ''b'' [[c]] &amp; <br/> __NOTOC__ https://x.org RFC 1",
            "Text(a ) I[Text(b)] Text( ) Link(C) Text( ) Ent(&amp;=&) Text( ) Html(br/s) \
             Text( ) Sw(notoc:__NOTOC__) Text( ) Auto(https://x.org=https://x.org) Text( ) \
             Magic(Rfc:RFC 1)",
        );
    }
}