wikioxide 0.1.0

MediaWiki wikitext parser producing typed domain models (templates, links, categories, warnings) with template expansion — no HTML, no network
Documentation
//! Section-tree extraction: builds the nested heading/section structure with byte spans
//! into the ORIGINAL page source (mapped through the flatten [`ProvMap`]). This is the
//! foundation for section- and discussion-aware consumers that slice or move raw wikitext.
//! Parse-only — expanded text carries no source map, so it grows no sections.

use crate::engine::ast::{Block, DocTree, InlineContent};
use crate::engine::provenance::ProvMap;
use crate::engine::strip::StripState;
use crate::model::{Section, Span};

use super::plaintext;

/// A section still being assembled: its finished span is known only once the next
/// same-or-higher heading (or end of source) fixes its body end.
struct Builder {
    level: u8,
    heading_span: Span,
    title_span: Span,
    title_text: String,
    body_start: usize,
    children: Vec<Section>,
}

impl Builder {
    fn finish(self, body_end: usize) -> Section {
        Section::new(
            self.level,
            self.heading_span,
            self.title_span,
            Span {
                start: self.body_start,
                end: body_end,
            },
            self.title_text,
            self.children,
        )
    }
}

/// Builds the top-level sections (each nesting its deeper subsections) from the parsed
/// block tree. Headings are always top-level blocks, so only `tree.blocks` is scanned.
pub(crate) fn build(
    tree: &DocTree,
    flat: &str,
    strips: &StripState,
    prov: &ProvMap,
) -> Vec<Section> {
    let mut roots: Vec<Section> = Vec::new();
    let mut stack: Vec<Builder> = Vec::new();
    for block in &tree.blocks {
        let Block::Heading {
            level,
            line,
            inner,
            content,
        } = block
        else {
            continue;
        };
        let heading_span = to_span(prov, line.start, line.end);
        while stack.last().is_some_and(|b| b.level >= *level) {
            if let Some(top) = stack.pop() {
                let done = top.finish(heading_span.start);
                attach(&mut stack, &mut roots, done);
            }
        }
        stack.push(Builder {
            level: *level,
            heading_span,
            title_span: to_span(prov, inner.start, inner.end),
            title_text: title_of(content, flat, strips),
            body_start: heading_span.start,
            children: Vec::new(),
        });
    }
    let end = prov.src_len() as usize;
    while let Some(top) = stack.pop() {
        let done = top.finish(end);
        attach(&mut stack, &mut roots, done);
    }
    roots
}

/// Attaches a finished section to its parent (the section now open above it) or, when none
/// remains open, to the document's root sections.
fn attach(stack: &mut [Builder], roots: &mut Vec<Section>, done: Section) {
    match stack.last_mut() {
        Some(parent) => parent.children.push(done),
        None => roots.push(done),
    }
}

/// Maps a flat `[start, end)` range to an original-source span.
fn to_span(prov: &ProvMap, start: u32, end: u32) -> Span {
    Span {
        start: prov.source_offset(start) as usize,
        end: prov.source_offset(end) as usize,
    }
}

/// Renders a heading's parsed title to plain text. `Raw` content only occurs before the
/// inline pass (the section builder runs after it), so it defensively yields an empty title.
fn title_of(content: &InlineContent, flat: &str, strips: &StripState) -> String {
    match content {
        InlineContent::Parsed(nodes) => plaintext::label_text(nodes, flat, strips),
        InlineContent::Raw(_) => String::new(),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::engine::ast::TextRange;
    use crate::engine::source::{SourceId, Span as SrcSpan};

    /// A heading whose content is still `Raw` (pre-inline-pass) yields an empty title but a
    /// well-formed span — the defensive arm the real pipeline never reaches.
    #[test]
    fn raw_heading_content_yields_an_empty_title() {
        let mut prov = ProvMap::new(8);
        prov.push_text(0, SrcSpan::new(SourceId(0), 0, 8));
        prov.seal(8);
        let tree = DocTree {
            blocks: vec![Block::Heading {
                level: 2,
                line: TextRange::new(0, 8),
                inner: TextRange::new(3, 5),
                content: InlineContent::Raw(TextRange::new(3, 5)),
            }],
        };
        let sections = build(&tree, "== A ==", &StripState::new(), &prov);
        assert_eq!(sections.len(), 1);
        assert_eq!(sections[0].title_text(), "");
        assert_eq!(sections[0].level(), 2);
        assert_eq!(sections[0].body_span(), Span { start: 0, end: 8 });
    }

    /// A non-heading block is skipped without affecting the (empty) section list.
    #[test]
    fn documents_without_headings_have_no_sections() {
        let prov = ProvMap::new(0);
        let tree = DocTree {
            blocks: vec![Block::HorizontalRule],
        };
        assert!(build(&tree, "", &StripState::new(), &prov).is_empty());
    }
}