wikioxide 0.1.0

MediaWiki wikitext parser producing typed domain models (templates, links, categories, warnings) with template expansion — no HTML, no network
Documentation
//! AST contract between the pipeline stages.
//!
//! The **block pass** (line-oriented: headings, lists, tables, indent-pre) produces
//! [`Block`]s whose inline payloads start as [`InlineContent::Raw`] ranges into the
//! flattened text. The **inline pass** later replaces each `Raw` with
//! [`InlineContent::Parsed`] nodes — one line at a time, because quote resolution and
//! link trails are per-line constructs. Extraction walks the finished tree.
//!
//! All ranges index the *flattened* marker text (comments gone, ext tags and — in
//! parse-only mode — template calls replaced by strip markers), not the original source.

use crate::engine::strip::{MarkerId, MarkerKind};
use crate::title::Title;

/// A byte range into the flattened text. Always on char boundaries.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub(crate) struct TextRange {
    pub(crate) start: u32,
    pub(crate) end: u32,
}

impl TextRange {
    pub(crate) fn new(start: u32, end: u32) -> Self {
        debug_assert!(start <= end);
        Self { start, end }
    }

    pub(crate) fn slice(self, text: &str) -> &str {
        &text[self.start as usize..self.end as usize]
    }

    pub(crate) fn is_empty(self) -> bool {
        self.start == self.end
    }
}

/// Inline payload of a block: a raw line range before the inline pass, parsed nodes after.
#[derive(Debug, Clone, PartialEq)]
pub(crate) enum InlineContent {
    Raw(TextRange),
    Parsed(Vec<Inline>),
}

/// A block-level node.
#[derive(Debug, Clone, PartialEq)]
pub(crate) enum Block {
    /// Consecutive plain-text lines (one `InlineContent` per line).
    Paragraph { lines: Vec<InlineContent> },
    /// `== heading ==`; level already clamped to 1–6, content trimmed of `=` runs.
    /// `line` is the whole heading line (both `=` runs included); `inner` is the trimmed
    /// title range — both index the flattened text, for source-span attribution.
    Heading {
        level: u8,
        line: TextRange,
        inner: TextRange,
        content: InlineContent,
    },
    /// Leading-space preformatted block; one entry per source line.
    IndentPre { lines: Vec<InlineContent> },
    /// `----`
    HorizontalRule,
    /// A `*`/`#`/`;`/`:` list. Nested lists live inside items' `children`.
    List {
        kind: ListKind,
        items: Vec<ListItem>,
    },
    /// A `{| … |}` table.
    Table(Table),
}

/// List family.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ListKind {
    /// `*`
    Bullet,
    /// `#`
    Ordered,
    /// `;` / `:` (definition list; `:`-only indentation is also a definition list).
    Definition,
}

/// The marker that opened a list item.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ListItemKind {
    /// `*` or `#` item.
    Item,
    /// `;` term.
    Term,
    /// `:` definition / indentation.
    Definition,
}

/// One list item: its own line content plus nested blocks (sublists).
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct ListItem {
    pub(crate) kind: ListItemKind,
    /// The item's inline text; `None` when the line only opened deeper nesting.
    pub(crate) content: Option<InlineContent>,
    pub(crate) children: Vec<Block>,
}

/// A table.
#[derive(Debug, Clone, PartialEq, Default)]
pub(crate) struct Table {
    /// Raw attribute text after `{|`.
    pub(crate) attributes: Option<TextRange>,
    pub(crate) caption: Option<TableCaption>,
    pub(crate) rows: Vec<TableRow>,
    /// False when `|}` was missing and the table auto-closed at end of input.
    pub(crate) closed: bool,
}

/// `|+ caption`.
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct TableCaption {
    pub(crate) attributes: Option<TextRange>,
    pub(crate) content: InlineContent,
}

/// A table row (`|-` or the implicit first row).
#[derive(Debug, Clone, PartialEq, Default)]
pub(crate) struct TableRow {
    /// Raw attribute text after `|-`.
    pub(crate) attributes: Option<TextRange>,
    pub(crate) cells: Vec<TableCell>,
}

/// One `|`/`!` cell.
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct TableCell {
    pub(crate) header: bool,
    /// Raw attribute segment before the second `|`, if present.
    pub(crate) attributes: Option<TextRange>,
    /// Inline content on the cell's own line.
    pub(crate) content: InlineContent,
    /// Continuation lines: full blocks (lists, nested tables, paragraphs).
    pub(crate) children: Vec<Block>,
}

/// An inline node (within one line).
#[derive(Debug, Clone, PartialEq)]
pub(crate) enum Inline {
    /// Plain text.
    Text(TextRange),
    /// A decoded character entity (`&nbsp;`, `&#65;`, `&#x41;`).
    Entity { range: TextRange, decoded: char },
    /// A strip marker (extension tag or unexpanded template/argument).
    Marker { id: MarkerId, kind: MarkerKind },
    /// `[[Target|label]]trail` — an ordinary internal link (not category/file/interwiki).
    Link {
        target: Title,
        /// The target text exactly as written inside the brackets, before any `|`
        /// (`[[кот]]` keeps `кот`; `[[:Category:X]]` keeps the leading colon).
        written: TextRange,
        /// Fragment after `#` in the target, if any.
        fragment: Option<String>,
        /// Pipe label, parsed as inline nodes; `None` for `[[bare]]` links.
        label: Option<Vec<Inline>>,
        /// Link-trail letters glued to the closing brackets (`[[dog]]s`).
        trail: Option<TextRange>,
        /// Whether the pipe-trick form `[[X|]]` was used.
        pipe_trick: bool,
    },
    /// `[[Category:X|sortkey]]` — categorization, no visible output.
    Category {
        category: Title,
        sortkey: Option<String>,
    },
    /// `[[fr:Chat]]` — interlanguage/interwiki link. Without the leading-colon escape
    /// it is a sidebar entry with no visible output; the escaped form (`[[:fr:Chat]]`)
    /// is an inline interwiki link that renders in the body.
    InterLanguage {
        prefix: String,
        target: String,
        /// Whether the `[[:fr:X]]` leading-colon escape was used.
        escaped: bool,
    },
    /// `[[File:X|params|caption]]`.
    Image {
        target: Title,
        /// Raw parameter segments (thumb, upright=1.2, alt=…), unparsed.
        params: Vec<TextRange>,
        /// The caption (last unrecognized segment), inline-parsed.
        caption: Option<Vec<Inline>>,
    },
    /// `[http://x label]`.
    ExternalLink {
        url: String,
        label: Option<Vec<Inline>>,
    },
    /// A bare URL that auto-linked.
    AutoUrl { url: String, range: TextRange },
    /// `RFC 2324` / `PMID 1234` / `ISBN 978-…` magic link.
    MagicLink {
        kind: MagicLinkKind,
        range: TextRange,
    },
    /// `''italic''` span.
    Italic(Vec<Inline>),
    /// `'''bold'''` span.
    Bold(Vec<Inline>),
    /// An allowed HTML tag kept in the document (`<sup>`, `<br/>`, `<span class=…>`).
    HtmlTag {
        /// Lower-cased tag name.
        name: String,
        /// True for `</closing>` tags.
        closing: bool,
        self_closing: bool,
        /// Raw attribute text.
        attributes: Option<TextRange>,
    },
    /// `__NOTOC__`-style behavior switch, resolved to its magic-word id and removed
    /// from rendered output.
    BehaviorSwitch { id: String, range: TextRange },
}

/// Which magic-link family matched.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum MagicLinkKind {
    Rfc,
    Pmid,
    Isbn,
}

/// The finished document: block tree over the flattened text.
#[derive(Debug, Default)]
pub(crate) struct DocTree {
    pub(crate) blocks: Vec<Block>,
}

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

    #[test]
    fn text_ranges_slice_flattened_text() {
        let text = "аб cd";
        let range = TextRange::new(0, 4);
        assert_eq!(range.slice(text), "аб");
        assert!(!range.is_empty());
        assert!(TextRange::new(2, 2).is_empty());
    }

    #[test]
    fn inline_content_transitions_raw_to_parsed() {
        let raw = InlineContent::Raw(TextRange::new(0, 4));
        let parsed = InlineContent::Parsed(vec![Inline::Text(TextRange::new(0, 4))]);
        assert_ne!(raw, parsed);
        assert_eq!(raw.clone(), raw);
    }

    #[test]
    fn block_tree_nests_lists_and_tables() {
        let item = ListItem {
            kind: ListItemKind::Item,
            content: Some(InlineContent::Raw(TextRange::new(2, 8))),
            children: vec![Block::List {
                kind: ListKind::Ordered,
                items: vec![ListItem {
                    kind: ListItemKind::Item,
                    content: None,
                    children: Vec::new(),
                }],
            }],
        };
        let table = Table {
            attributes: Some(TextRange::new(2, 20)),
            caption: Some(TableCaption {
                attributes: None,
                content: InlineContent::Raw(TextRange::new(24, 30)),
            }),
            rows: vec![TableRow {
                attributes: None,
                cells: vec![TableCell {
                    header: true,
                    attributes: Some(TextRange::new(33, 40)),
                    content: InlineContent::Raw(TextRange::new(41, 50)),
                    children: Vec::new(),
                }],
            }],
            closed: true,
        };
        let doc = DocTree {
            blocks: vec![
                Block::Heading {
                    level: 2,
                    line: TextRange::new(0, 10),
                    inner: TextRange::new(3, 10),
                    content: InlineContent::Raw(TextRange::new(3, 10)),
                },
                Block::List {
                    kind: ListKind::Bullet,
                    items: vec![item],
                },
                Block::Table(table),
                Block::HorizontalRule,
                Block::IndentPre {
                    lines: vec![InlineContent::Raw(TextRange::new(60, 70))],
                },
                Block::Paragraph {
                    lines: vec![InlineContent::Raw(TextRange::new(71, 80))],
                },
            ],
        };
        assert_eq!(doc.blocks.len(), 6);
        let debug = format!("{doc:?}");
        assert!(debug.contains("Heading") && debug.contains("Table"));
        assert_ne!(ListKind::Bullet, ListKind::Definition);
        assert_ne!(ListItemKind::Term, ListItemKind::Definition);
        assert_eq!(Table::default().rows.len(), 0);
        assert_eq!(TableRow::default().cells.len(), 0);
    }

    #[test]
    fn inline_variants_cover_the_construct_families() {
        use crate::config::SiteConfig;
        let config = SiteConfig::builder("en", "W")
            .namespace(crate::config::NamespaceDef::main())
            .build()
            .unwrap();
        let title = Title::parse("Page", &config).unwrap();
        let nodes = [
            Inline::Text(TextRange::new(0, 1)),
            Inline::Entity {
                range: TextRange::new(1, 7),
                decoded: '\u{a0}',
            },
            Inline::Marker {
                id: MarkerId(0),
                kind: MarkerKind::ExtTag,
            },
            Inline::Link {
                target: title.clone(),
                written: TextRange::new(2, 6),
                fragment: Some("s".to_owned()),
                label: Some(vec![Inline::Text(TextRange::new(9, 10))]),
                trail: Some(TextRange::new(12, 13)),
                pipe_trick: false,
            },
            Inline::Category {
                category: title.clone(),
                sortkey: Some("k".to_owned()),
            },
            Inline::InterLanguage {
                prefix: "fr".to_owned(),
                target: "Chat".to_owned(),
                escaped: false,
            },
            Inline::Image {
                target: title.clone(),
                params: vec![TextRange::new(20, 25)],
                caption: Some(Vec::new()),
            },
            Inline::ExternalLink {
                url: "https://example.com".to_owned(),
                label: None,
            },
            Inline::AutoUrl {
                url: "https://example.com".to_owned(),
                range: TextRange::new(30, 49),
            },
            Inline::MagicLink {
                kind: MagicLinkKind::Isbn,
                range: TextRange::new(50, 64),
            },
            Inline::Italic(vec![Inline::Bold(Vec::new())]),
            Inline::HtmlTag {
                name: "br".to_owned(),
                closing: false,
                self_closing: true,
                attributes: None,
            },
            Inline::BehaviorSwitch {
                id: "notoc".to_owned(),
                range: TextRange::new(70, 79),
            },
        ];
        assert_eq!(nodes.len(), 13);
        assert_ne!(MagicLinkKind::Rfc, MagicLinkKind::Pmid);
        assert_eq!(nodes[0].clone(), nodes[0]);
    }
}