pub mod braces;
pub mod functions;
pub mod template;
pub mod templates;
use std::collections::HashMap;
use std::sync::Arc;
use braces::find_matching_braces;
use functions::call_parser_function;
use std::sync::LazyLock;
use regex::Regex;
use template::Template;
pub use templates::{LazyTemplateSource, TemplateDb};
const MAX_TEMPLATE_RECURSION: usize = 30;
pub trait TemplateSource {
fn qualify(&self, title: &str) -> String;
fn get_parsed(&self, qualified_title: &str) -> Option<Arc<Template>>;
}
pub struct Expander<'a> {
magic_words: HashMap<&'static str, String>,
frame: Vec<(String, HashMap<String, String>)>,
source: &'a dyn TemplateSource,
}
impl<'a> Expander<'a> {
pub fn new(page_title: &str, source: &'a dyn TemplateSource) -> Self {
let now = jiff::Zoned::now();
let strftime = |fmt: &str| now.strftime(fmt).to_string();
let namespace = match page_title.find(':') {
Some(colon) => page_title[..colon].to_string(),
None => String::new(),
};
let magic_words = HashMap::from([
("!", "|".to_string()),
("namespace", namespace),
("pagename", page_title.to_string()),
("fullpagename", page_title.to_string()),
("currentyear", strftime("%Y")),
("currentmonth", strftime("%m")),
("currentday", strftime("%d")),
("currenthour", strftime("%H")),
("currenttime", strftime("%H:%M:%S")),
]);
Self {
magic_words,
frame: Vec::new(),
source,
}
}
pub fn expand(&mut self, text: &str) -> String {
if self.frame.len() >= MAX_TEMPLATE_RECURSION {
log::debug!("max template recursion exceeded");
return String::new();
}
let mut result = String::with_capacity(text.len());
let mut cur = 0;
for (s, e) in find_matching_braces(text, 2) {
result.push_str(&text[cur..s]);
let body = text.get(s + 2..e.saturating_sub(2)).unwrap_or("");
result.push_str(&self.expand_invocation(body));
cur = e;
}
result.push_str(&text[cur..]);
result
}
fn expand_invocation(&mut self, body: &str) -> String {
if self.frame.len() >= MAX_TEMPLATE_RECURSION {
log::debug!("max template recursion exceeded");
return String::new();
}
let parts = split_parts(body);
let expanded_title = self.expand(parts[0].trim());
let (title, subst) = strip_subst(&expanded_title);
if let Some(value) = self.magic_words.get(title.to_lowercase().as_str()) {
return value.clone();
}
if let Some(colon) = title.find(':')
&& title[..colon].chars().count() > 1
{
let function = &title[..colon];
let mut args: Vec<String> = parts.iter().map(|p| p.to_string()).collect();
args[0] = title[colon + 1..].trim().to_string();
let result = call_parser_function(function, &args);
return self.expand(&result);
}
let qualified = self.source.qualify(title);
if qualified.is_empty() {
log::debug!("empty template title in invocation");
return String::new();
}
let Some(template) = self.source.get_parsed(&qualified) else {
return String::new();
};
let mut params: Vec<String> = parts[1..].to_vec();
if !subst {
params = params.iter().map(|p| self.expand(p)).collect();
}
let params = template_params(¶ms);
if self
.frame
.iter()
.any(|(frame_title, frame_params)| *frame_title == qualified && *frame_params == params)
{
log::debug!("template loop detected: {qualified}");
return format!("{{{{{body}}}}}");
}
self.frame.push((qualified, params.clone()));
let instantiated = template.subst(¶ms, self, 0);
let value = self.expand(&instantiated);
self.frame.pop();
value
}
}
fn strip_subst(title: &str) -> (&str, bool) {
for prefix in ["subst:", "safesubst:"] {
if title.len() >= prefix.len()
&& title.as_bytes()[..prefix.len()].eq_ignore_ascii_case(prefix.as_bytes())
{
return (&title[prefix.len()..], true);
}
}
(title, false)
}
static NAMED_PARAM: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?s)^ *([^=']*?) *=(.*)").unwrap());
fn template_params(params: &[String]) -> HashMap<String, String> {
let mut map = HashMap::new();
let mut unnamed = 0;
for param in params {
if let Some(caps) = NAMED_PARAM.captures(param) {
let name = caps[1].trim().to_string();
let mut value = caps.get(2).expect("value group").as_str();
if !value.contains("]]") {
value = value.trim();
}
map.insert(name, value.to_string());
} else {
unnamed += 1;
let value = if param.contains("]]") {
param.as_str()
} else {
param.trim()
};
map.insert(unnamed.to_string(), value.to_string());
}
}
map
}
pub fn split_parts(params_list: &str) -> Vec<String> {
let mut parameters: Vec<String> = Vec::new();
let mut cur = 0;
let append_split = |parameters: &mut Vec<String>, segment: &str| {
let mut pieces = segment.split('|');
let first = pieces.next().expect("split yields at least one piece");
match parameters.last_mut() {
Some(last) => last.push_str(first),
None => parameters.push(first.to_string()),
}
parameters.extend(pieces.map(str::to_string));
};
for (s, e) in find_matching_braces(params_list, 0) {
append_split(&mut parameters, ¶ms_list[cur..s]);
parameters
.last_mut()
.expect("at least one parameter exists")
.push_str(¶ms_list[s..e]);
cur = e;
}
append_split(&mut parameters, ¶ms_list[cur..]);
parameters
}
#[cfg(test)]
mod tests {
use super::*;
fn expand(text: &str) -> String {
let db = TemplateDb::default();
Expander::new("Test page", &db).expand(text)
}
fn expand_with(templates: &[(&str, &str)], text: &str) -> String {
let mut db = TemplateDb::default();
for (title, body) in templates {
db.define(title, body);
}
Expander::new("Test page", &db).expand(text)
}
#[test]
fn unknown_templates_expand_to_nothing() {
assert_eq!(expand("a {{lang-grc|x|y}} b"), "a b");
assert_eq!(expand("a {{nested|x={{deep|y}}}} b"), "a b");
}
#[test]
fn top_level_template_arguments_vanish() {
assert_eq!(expand("a {{{1|default}}} b"), "a b");
}
#[test]
fn magic_words_expand() {
assert_eq!(expand("{{PAGENAME}}"), "Test page");
assert_eq!(expand("a{{!}}b"), "a|b");
let db = TemplateDb::default();
assert_eq!(Expander::new("Talk:X", &db).expand("{{NAMESPACE}}"), "Talk");
}
#[test]
fn parser_functions_evaluate() {
assert_eq!(expand("{{#if:x|yes|no}}"), "yes");
assert_eq!(expand("{{#if:|yes|no}}"), "no");
assert_eq!(expand("{{#expr:2*3+4}}"), "10");
assert_eq!(expand("{{lc:ABC}}"), "abc");
assert_eq!(expand("{{#switch:b|a=1|b=2}}"), "2");
}
#[test]
fn branches_are_expanded_lazily_from_results() {
assert_eq!(expand("{{#if:x|{{#ifeq:a|a|inner}}|other}}"), "inner");
assert_eq!(expand("{{#if:x|{{unknown template}}rest}}"), "rest");
}
#[test]
fn subst_prefix_is_stripped() {
assert_eq!(expand("{{subst:lc:ABC}}"), "abc");
assert_eq!(expand("{{safesubst:PAGENAME}}"), "Test page");
assert_eq!(expand("{{lang\u{2013}x|y}}"), "");
assert_eq!(expand("{{\u{fc}ber}}"), "");
}
#[test]
fn split_parts_respects_nesting() {
assert_eq!(
split_parts("t|a|b={{x|y}}|c [[l|lab]] d"),
vec!["t", "a", "b={{x|y}}", "c [[l|lab]] d"]
);
assert_eq!(split_parts(""), vec![""]);
assert_eq!(split_parts("a||c"), vec!["a", "", "c"]);
}
#[test]
fn defined_templates_instantiate() {
let templates = [("Template:Greet", "Hello {{{name|stranger}}}!")];
assert_eq!(
expand_with(&templates, "{{Greet|name=World}}"),
"Hello World!"
);
assert_eq!(expand_with(&templates, "{{greet}}"), "Hello stranger!");
}
#[test]
fn nested_template_invocations_expand() {
let templates = [
("Template:Inner", "[{{{1}}}]"),
("Template:Outer", "out {{Inner|{{{x}}}}} out"),
];
assert_eq!(expand_with(&templates, "{{Outer|x=42}}"), "out [42] out");
}
#[test]
fn parameters_may_contain_templates() {
let templates = [("Template:Id", "{{{1}}}")];
assert_eq!(expand_with(&templates, "{{Id|{{lc:ABC}}}}"), "abc");
}
#[test]
fn later_named_parameter_overrides_positional() {
let templates = [("Template:T", "{{{1}}}-{{{2}}}-{{{3}}}")];
assert_eq!(expand_with(&templates, "{{T|a|b|c|2=B}}"), "a-B-c");
}
#[test]
fn template_redirects_resolve() {
let templates = [
("Template:Real", "value"),
("Template:Alias", "#REDIRECT [[Template:Real]]"),
];
assert_eq!(expand_with(&templates, "{{Alias}}"), "value");
}
#[test]
fn zero_progress_loops_stay_literal() {
let templates = [("Template:Loop", "x{{Loop}}")];
assert_eq!(expand_with(&templates, "{{Loop}}"), "x{{Loop}}");
}
#[test]
fn self_recursion_with_different_params_progresses() {
let templates = [(
"Template:Count",
"{{{1|}}}{{#if:{{{2|}}}|{{Count|{{{2}}}|{{{3|}}}}}}}",
)];
assert_eq!(expand_with(&templates, "{{Count|a|b|c}}"), "abc");
}
#[test]
fn recursion_limit_returns_empty() {
let templates = [(
"Template:Inf",
"{{#expr:{{{1}}}+1}}{{Inf|{{#expr:{{{1}}}+1}}}}",
)];
let result = expand_with(&templates, "{{Inf|0}}");
assert!(result.starts_with("123"), "unexpected: {result}");
}
}