wikioxide 0.1.0

MediaWiki wikitext parser producing typed domain models (templates, links, categories, warnings) with template expansion — no HTML, no network
Documentation
//! The parser facade: configure once per wiki, parse many pages.

use std::fmt;
use std::sync::Arc;

use crate::clock::{Clock, SystemClock, Timestamp};
use crate::config::{Limits, SiteConfig};
use crate::doc::{ExpandOutcome, ParsedDocument};
use crate::engine::expand::UnresolvedPolicy;
use crate::error::BuildError;
use crate::source::{EmptyTemplateSource, ExistenceProvider, TemplateSource};
use crate::title::Title;

/// A configured, reusable, thread-safe wikitext parser. Build one per wiki and share it —
/// `Clone` is cheap (`Arc` inside).
#[derive(Clone)]
pub struct WikitextParser {
    inner: Arc<Inner>,
}

struct Inner {
    config: SiteConfig,
    limits: Limits,
    clock: Arc<dyn Clock>,
    source: Arc<dyn TemplateSource>,
    existence: Option<Arc<dyn ExistenceProvider>>,
    policy: UnresolvedPolicy,
}

impl fmt::Debug for WikitextParser {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("WikitextParser")
            .field("lang", &self.inner.config.lang())
            .field("limits", &self.inner.limits)
            .finish_non_exhaustive()
    }
}

impl WikitextParser {
    /// Starts configuring a parser.
    #[must_use]
    pub fn builder() -> WikitextParserBuilder {
        WikitextParserBuilder {
            config: None,
            limits: Limits::default(),
            clock: Arc::new(SystemClock),
            source: Arc::new(EmptyTemplateSource),
            existence: None,
            policy: UnresolvedPolicy::RedLink,
        }
    }

    /// The site configuration this parser was built with.
    #[must_use]
    pub fn site_config(&self) -> &SiteConfig {
        &self.inner.config
    }

    /// Parses wikitext WITHOUT template expansion: the as-written view. Total over
    /// input — malformed markup yields a document plus warnings, never an error.
    #[must_use]
    pub fn parse(&self, wikitext: &str, ctx: &PageContext) -> ParsedDocument {
        let now = ctx.now.unwrap_or_else(|| self.inner.clock.now());
        crate::engine::pipeline::parse_only(
            &self.inner.config,
            &self.inner.limits,
            wikitext,
            ctx.title.as_ref(),
            now,
        )
    }

    /// Parses wikitext WITH template expansion: transclusions, `{{{arguments}}}` and
    /// parser functions are resolved via the configured
    /// [`TemplateSource`](crate::TemplateSource), then the post-expansion sets are
    /// extracted. Total over input — never an error.
    ///
    /// Under [`UnresolvedPolicy::Halt`] a pass that needs uncached templates returns
    /// [`ExpandOutcome::MissingTemplates`]; fetch them, add them to the source, and call
    /// `expand` again (the store warms across calls).
    #[must_use]
    pub fn expand(&self, wikitext: &str, ctx: &PageContext) -> ExpandOutcome {
        let now = ctx.now.unwrap_or_else(|| self.inner.clock.now());
        let title = ctx
            .title
            .clone()
            .unwrap_or_else(|| Title::synthetic_main("API"));
        crate::engine::pipeline::expand_page(
            &self.inner.config,
            &self.inner.limits,
            wikitext,
            &title,
            now,
            self.inner.source.as_ref(),
            self.inner.existence.as_deref(),
            self.inner.policy,
        )
    }
}

/// Builder for [`WikitextParser`].
pub struct WikitextParserBuilder {
    config: Option<SiteConfig>,
    limits: Limits,
    clock: Arc<dyn Clock>,
    source: Arc<dyn TemplateSource>,
    existence: Option<Arc<dyn ExistenceProvider>>,
    policy: UnresolvedPolicy,
}

impl fmt::Debug for WikitextParserBuilder {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("WikitextParserBuilder")
            .field("config", &self.config.as_ref().map(SiteConfig::lang))
            .field("limits", &self.limits)
            .finish_non_exhaustive()
    }
}

impl WikitextParserBuilder {
    /// Sets the per-wiki configuration (required unless the `presets` feature supplies
    /// the generic default).
    #[must_use]
    pub fn site_config(mut self, config: SiteConfig) -> Self {
        self.config = Some(config);
        self
    }

    /// Overrides the resource limits (defaults mirror Wikimedia production).
    #[must_use]
    pub fn limits(mut self, limits: Limits) -> Self {
        self.limits = limits;
        self
    }

    /// Injects a clock for `{{CURRENT*}}`-family determinism (tests/goldens use
    /// [`FixedClock`](crate::FixedClock)).
    #[must_use]
    pub fn clock(mut self, clock: Arc<dyn Clock>) -> Self {
        self.clock = clock;
        self
    }

    /// Sets where [`expand`](WikitextParser::expand) fetches template/module wikitext
    /// (default: [`EmptyTemplateSource`](crate::EmptyTemplateSource) — every page is a red
    /// link).
    #[must_use]
    pub fn template_source(mut self, source: Arc<dyn TemplateSource>) -> Self {
        self.source = source;
        self
    }

    /// Sets the existence oracle `{{#ifexist:}}` consults (default: none — `#ifexist`
    /// records the query and takes the false branch).
    #[must_use]
    pub fn existence_provider(mut self, provider: Arc<dyn ExistenceProvider>) -> Self {
        self.existence = Some(provider);
        self
    }

    /// Sets what [`expand`](WikitextParser::expand) does with unresolved template pages
    /// (default: [`UnresolvedPolicy::RedLink`]).
    #[must_use]
    pub fn on_unresolved(mut self, policy: UnresolvedPolicy) -> Self {
        self.policy = policy;
        self
    }

    /// Validates the configuration and builds the parser.
    pub fn build(self) -> Result<WikitextParser, BuildError> {
        self.limits.validate()?;
        let config = match self.config {
            Some(config) => config,
            #[cfg(feature = "presets")]
            None => SiteConfig::generic().clone(),
            #[cfg(not(feature = "presets"))]
            None => return Err(BuildError::MissingSiteConfig),
        };
        Ok(WikitextParser {
            inner: Arc::new(Inner {
                config,
                limits: self.limits,
                clock: self.clock,
                source: self.source,
                existence: self.existence,
                policy: self.policy,
            }),
        })
    }
}

#[cfg(test)]
mod tests {
    use std::sync::Arc;

    use super::*;
    use crate::clock::FixedClock;
    use crate::config::NamespaceDef;
    use crate::doc::DocumentView;

    fn tiny_config() -> SiteConfig {
        SiteConfig::builder("en", "Wiki")
            .namespace(NamespaceDef::main())
            .build()
            .unwrap()
    }

    #[test]
    fn builder_wires_config_limits_and_clock() {
        let parser = WikitextParser::builder()
            .site_config(tiny_config())
            .limits(Limits::default().with_expansion_depth(40))
            .clock(Arc::new(FixedClock::new(Timestamp::from_unix(42))))
            .build()
            .unwrap();
        assert_eq!(parser.site_config().lang(), "en");
        assert_eq!(parser.inner.limits.expansion_depth(), 40);
        let clone = parser.clone();
        assert!(format!("{clone:?}").contains("WikitextParser"));
    }

    #[test]
    fn invalid_limits_fail_the_build() {
        let err = WikitextParser::builder()
            .site_config(tiny_config())
            .limits(Limits::default().with_expansion_depth(0))
            .build()
            .unwrap_err();
        assert!(matches!(err, crate::error::BuildError::InvalidLimits(_)));
    }

    #[cfg(feature = "presets")]
    #[test]
    fn default_config_is_the_generic_preset() {
        let parser = WikitextParser::builder().build().unwrap();
        assert_eq!(parser.site_config().lang(), "en");
        let doc = parser.parse("[[Page]]", &PageContext::default());
        assert_eq!(doc.links().len(), 1);
    }

    #[cfg(not(feature = "presets"))]
    #[test]
    fn missing_config_without_presets_is_an_error() {
        let err = WikitextParser::builder().build().unwrap_err();
        assert!(matches!(err, crate::error::BuildError::MissingSiteConfig));
    }

    #[test]
    fn page_context_carries_title_and_frozen_now() {
        let config = tiny_config();
        let title = crate::title::Title::parse("Page", &config).unwrap();
        let ctx = PageContext::for_title(title).at(Timestamp::from_unix(7));
        assert!(format!("{ctx:?}").contains("PageContext"));

        let parser = WikitextParser::builder()
            .site_config(config)
            .build()
            .unwrap();
        let doc = parser.parse("text", &ctx);
        assert_eq!(doc.plain_text(), "text");

        let builder_debug = format!("{:?}", WikitextParser::builder());
        assert!(builder_debug.contains("WikitextParserBuilder"));
    }
}

/// Per-page parse context: the page's own title (drives `{{PAGENAME}}` and self-link
/// detection once expansion lands) and an optional fixed "now" override.
#[derive(Debug, Clone, Default)]
pub struct PageContext {
    title: Option<Title>,
    now: Option<Timestamp>,
}

impl PageContext {
    /// A context for parsing the given page.
    #[must_use]
    pub fn for_title(title: Title) -> Self {
        Self {
            title: Some(title),
            now: None,
        }
    }

    /// Freezes "now" for this parse (otherwise the parser's clock is asked).
    #[must_use]
    pub fn at(mut self, now: Timestamp) -> Self {
        self.now = Some(now);
        self
    }
}