ruwex 0.1.0

Fast Rust rewrite of wikiextractor: extract and clean text from Wikimedia XML dumps
Documentation
//! Parsed template bodies: alternating fixed text and `{{{parameter}}}`
//! references, ported from wikiextractor's `Template` / `TemplateArg`.

use std::collections::HashMap;

use super::braces::find_matching_braces;
use super::{Expander, split_parts};

/// Parameter substitution deeper than this returns `''` (Python's
/// `maxParameterRecursionLevels`).
const MAX_PARAMETER_RECURSION: usize = 16;

/// A template body is a sequence of fixed text and parameter references.
#[derive(Debug)]
pub struct Template(Vec<TemplatePart>);

#[derive(Debug)]
enum TemplatePart {
    Text(String),
    Arg(TemplateArg),
}

/// A `{{{name|default}}}` reference; name and default may themselves
/// contain parameter references (e.g. `{{{italics|{{{italic|}}}}}}`).
#[derive(Debug)]
struct TemplateArg {
    name: Template,
    default: Option<Template>,
}

impl Template {
    pub fn parse(body: &str) -> Self {
        let mut parts = Vec::new();
        let mut start = 0;
        for (s, e) in find_matching_braces(body, 3) {
            parts.push(TemplatePart::Text(body[start..s].to_string()));
            // Degenerate spans (from the brace matcher's ambiguity handling,
            // e.g. `{{{{}}`) can be shorter than the six delimiter bytes;
            // Python's lenient slicing yields '' there, and so do we.
            let inner = body.get(s + 3..e.saturating_sub(3)).unwrap_or("");
            parts.push(TemplatePart::Arg(TemplateArg::new(inner)));
            start = e;
        }
        parts.push(TemplatePart::Text(body[start..].to_string()));
        Self(parts)
    }

    /// Substitutes invocation parameters into the body. Parameter names and
    /// defaults are themselves substituted and expanded, which is how names
    /// computed from other parameters (`{{{{{{p}}}}}}`) resolve.
    pub fn subst(
        &self,
        params: &HashMap<String, String>,
        expander: &mut Expander,
        depth: usize,
    ) -> String {
        if depth > MAX_PARAMETER_RECURSION {
            log::debug!("max parameter recursion exceeded");
            return String::new();
        }
        self.0
            .iter()
            .map(|part| match part {
                TemplatePart::Text(text) => text.clone(),
                TemplatePart::Arg(arg) => arg.subst(params, expander, depth),
            })
            .collect()
    }
}

impl TemplateArg {
    fn new(parameter: &str) -> Self {
        // any parts beyond the first two (name, default) are ignored, and an
        // equals sign in the first part is treated as plain text
        let parts = split_parts(parameter);
        Self {
            name: Template::parse(&parts[0]),
            default: parts.get(1).map(|default| Template::parse(default)),
        }
    }

    fn subst(
        &self,
        params: &HashMap<String, String>,
        expander: &mut Expander,
        depth: usize,
    ) -> String {
        let name = self.name.subst(params, expander, depth + 1);
        let name = expander.expand(&name);
        if let Some(value) = params.get(&name) {
            value.clone()
        } else if let Some(default) = &self.default {
            let default = default.subst(params, expander, depth + 1);
            expander.expand(&default)
        } else {
            String::new()
        }
    }
}

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

    fn subst(body: &str, params: &[(&str, &str)]) -> String {
        let params: HashMap<String, String> = params
            .iter()
            .map(|(k, v)| (k.to_string(), v.to_string()))
            .collect();
        let db = TemplateDb::default();
        let mut expander = Expander::new("Test", &db);
        Template::parse(body).subst(&params, &mut expander, 0)
    }

    #[test]
    fn substitutes_positional_and_named_parameters() {
        assert_eq!(
            subst("Hello {{{1}}} and {{{who}}}!", &[("1", "a"), ("who", "b")]),
            "Hello a and b!"
        );
    }

    #[test]
    fn missing_parameter_uses_default_or_empty() {
        assert_eq!(subst("x{{{1|default}}}y", &[]), "xdefaulty");
        assert_eq!(subst("x{{{1}}}y", &[]), "xy");
        assert_eq!(subst("x{{{1|}}}y", &[]), "xy");
    }

    #[test]
    fn nested_defaults_resolve() {
        assert_eq!(subst("{{{a|{{{b|fallback}}}}}}", &[("b", "B")]), "B");
        assert_eq!(subst("{{{a|{{{b|fallback}}}}}}", &[]), "fallback");
    }

    #[test]
    fn computed_parameter_names_resolve() {
        // Template:ppp containing {{{{{{p}}}}}} with p=q, q=r gives r
        assert_eq!(subst("{{{{{{p}}}}}}", &[("p", "q"), ("q", "r")]), "r");
    }

    #[test]
    fn extra_tplarg_parts_are_ignored() {
        assert_eq!(subst("{{{1|a|b|c}}}", &[]), "a");
    }
}