mod dispatch;
pub(crate) mod pf;
use std::collections::{BTreeMap, HashMap, HashSet};
use std::rc::Rc;
use crate::clock::Timestamp;
use crate::config::{Limits, SiteConfig};
use crate::engine::pp::{InclusionMode, Part, PpDom, PpKind, PpNodeId, build};
use crate::engine::source::{SourceStore, Span};
use crate::engine::strip::{StripPayload, StripState};
use crate::model::ModuleInvocation;
use crate::source::{ExistenceProvider, TemplateFetch, TemplateSource};
use crate::title::Title;
use crate::warn::{Warning, WarningCollector};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UnresolvedPolicy {
RedLink,
Halt,
}
pub(crate) struct ExpandContext<'a> {
pub(crate) title: Title,
pub(crate) now: Timestamp,
pub(crate) source: &'a dyn TemplateSource,
pub(crate) existence: Option<&'a dyn ExistenceProvider>,
pub(crate) policy: UnresolvedPolicy,
}
pub(crate) struct Expansion {
pub(crate) text: String,
pub(crate) used: Vec<Title>,
pub(crate) modules: Vec<ModuleInvocation>,
pub(crate) default_sort: Option<String>,
pub(crate) display_title: Option<String>,
pub(crate) wanted_existence: Vec<Title>,
pub(crate) missing: Vec<Title>,
}
pub(crate) fn expand(
store: &mut SourceStore,
page_dom: PpDom,
config: &SiteConfig,
limits: &Limits,
ctx: &ExpandContext<'_>,
strips: &mut StripState,
warnings: &mut WarningCollector,
) -> Expansion {
let mut engine = Expander {
store,
config,
limits,
ctx,
strips,
warnings,
doms: vec![Rc::new(page_dom)],
template_doms: HashMap::new(),
frames: vec![Frame {
title: ctx.title.clone(),
args: BTreeMap::new(),
}],
active: Vec::new(),
used: Vec::new(),
used_set: HashSet::new(),
modules: Vec::new(),
default_sort: None,
display_title: None,
wanted: Vec::new(),
wanted_set: HashSet::new(),
missing: Vec::new(),
missing_set: HashSet::new(),
node_count: 0,
post_expand: 0,
expensive: 0,
depth: 0,
hit_nodes: false,
hit_depth: false,
hit_size: false,
hit_expensive: false,
};
let mut out = String::new();
engine.expand_root(0, 0, &mut out);
Expansion {
text: out,
used: engine.used,
modules: engine.modules,
default_sort: engine.default_sort,
display_title: engine.display_title,
wanted_existence: engine.wanted,
missing: engine.missing,
}
}
struct Frame {
title: Title,
args: BTreeMap<String, BoundArg>,
}
struct BoundArg {
dom: usize,
nodes: Vec<PpNodeId>,
frame: usize,
trim: bool,
}
struct Expander<'a> {
store: &'a mut SourceStore,
config: &'a SiteConfig,
limits: &'a Limits,
ctx: &'a ExpandContext<'a>,
strips: &'a mut StripState,
warnings: &'a mut WarningCollector,
doms: Vec<Rc<PpDom>>,
template_doms: HashMap<Title, Option<usize>>,
frames: Vec<Frame>,
active: Vec<Title>,
used: Vec<Title>,
used_set: HashSet<Title>,
modules: Vec<ModuleInvocation>,
default_sort: Option<String>,
display_title: Option<String>,
wanted: Vec<Title>,
wanted_set: HashSet<Title>,
missing: Vec<Title>,
missing_set: HashSet<Title>,
node_count: u64,
post_expand: usize,
expensive: u32,
depth: u16,
hit_nodes: bool,
hit_depth: bool,
hit_size: bool,
hit_expensive: bool,
}
impl Expander<'_> {
fn expand_root(&mut self, dom_idx: usize, frame: usize, out: &mut String) {
let dom = Rc::clone(&self.doms[dom_idx]);
let root: Vec<PpNodeId> = dom.root().to_vec();
for id in root {
self.expand_node(&dom, dom_idx, id, frame, out);
}
}
fn expand_node(
&mut self,
dom: &Rc<PpDom>,
dom_idx: usize,
id: PpNodeId,
frame: usize,
out: &mut String,
) {
let span = dom.node(id).span;
if !self.charge_node() {
out.push_str(self.store.slice(span));
return;
}
match &dom.node(id).kind {
PpKind::Text => out.push_str(self.store.slice(span)),
PpKind::Comment | PpKind::Ignored => {}
PpKind::ExtTag {
name,
attributes,
body,
closed,
} => {
let payload = StripPayload::ExtTag {
name: self.store.slice(*name).to_lowercase(),
attributes: *attributes,
body: *body,
};
if !closed {
if let StripPayload::ExtTag { name, .. } = &payload {
let name = name.clone();
self.warnings.emit(Warning::UnclosedExtensionTag { name });
}
}
self.strips.add(out, payload);
}
PpKind::Template { parts } => self.expand_template(dom_idx, parts, span, frame, out),
PpKind::TplArg { parts } => self.expand_tplarg(dom_idx, parts, span, frame, out),
}
}
fn expand_template(
&mut self,
dom_idx: usize,
parts: &[Part],
span: Span,
frame: usize,
out: &mut String,
) {
let Some(name_part) = parts.first() else {
return;
};
let name = self.expand_part_all(dom_idx, name_part, frame);
let name = name.trim();
if name.is_empty() || name.contains('\n') || name.contains('\u{7f}') {
out.push_str(self.store.slice(span));
return;
}
let name = name.to_owned();
self.dispatch(dom_idx, &name, parts, span, frame, out);
}
fn expand_tplarg(
&mut self,
dom_idx: usize,
parts: &[Part],
span: Span,
frame: usize,
out: &mut String,
) {
let Some(name_part) = parts.first() else {
return;
};
let name = self
.expand_part_all(dom_idx, name_part, frame)
.trim()
.to_owned();
if let Some((arg_dom, nodes, arg_frame, trim)) = self.lookup_arg(frame, &name) {
let mut value = String::new();
self.expand_nodes(arg_dom, &nodes, arg_frame, &mut value);
out.push_str(if trim { value.trim() } else { &value });
return;
}
if let Some(default) = parts.get(1) {
self.expand_part_all_into(dom_idx, default, frame, out);
return;
}
out.push_str(self.store.slice(span));
}
fn expand_nodes(&mut self, dom_idx: usize, nodes: &[PpNodeId], frame: usize, out: &mut String) {
let dom = Rc::clone(&self.doms[dom_idx]);
for &id in nodes {
self.expand_node(&dom, dom_idx, id, frame, out);
}
}
fn expand_part_all(&mut self, dom_idx: usize, part: &Part, frame: usize) -> String {
let mut out = String::new();
self.expand_part_all_into(dom_idx, part, frame, &mut out);
out
}
fn expand_part_all_into(
&mut self,
dom_idx: usize,
part: &Part,
frame: usize,
out: &mut String,
) {
let nodes = part.children.clone();
self.expand_nodes(dom_idx, &nodes, frame, out);
}
fn lookup_arg(&self, frame: usize, name: &str) -> Option<(usize, Vec<PpNodeId>, usize, bool)> {
let arg = self.frames[frame].args.get(name)?;
Some((arg.dom, arg.nodes.clone(), arg.frame, arg.trim))
}
fn transclude(
&mut self,
title: Title,
dom_idx: usize,
parts: &[Part],
frame: usize,
out: &mut String,
) {
if self.active.contains(&title) {
self.warnings.emit(Warning::TemplateLoop {
title: title.text(),
});
self.emit_red_link(&title, out);
return;
}
if self.depth >= self.limits.expansion_depth() {
if !self.hit_depth {
self.hit_depth = true;
self.warnings.emit(Warning::ExpansionDepthExceeded {
limit: self.limits.expansion_depth(),
});
}
self.emit_red_link(&title, out);
return;
}
self.record_used(title.clone());
let Some(body_dom) = self.template_dom(&title) else {
self.emit_red_link(&title, out);
return;
};
let new_frame = self.bind_frame(title.clone(), dom_idx, parts, frame);
self.active.push(title);
self.depth += 1;
let mut body = String::new();
self.expand_root(body_dom, new_frame, &mut body);
self.depth -= 1;
self.active.pop();
self.frames.pop();
self.charge_post_expand(body.len());
out.push_str(&body);
}
fn bind_frame(&mut self, title: Title, dom_idx: usize, parts: &[Part], caller: usize) -> usize {
let mut args: BTreeMap<String, BoundArg> = BTreeMap::new();
let mut positional = 0_usize;
for part in parts.iter().skip(1) {
let (key, nodes, trim) = match part.eq {
Some(eq) if part.children.get(eq).is_some() => {
let key = self
.expand_nodes_owned(dom_idx, &part.children[..eq], caller)
.trim()
.to_owned();
(key, part.children[eq + 1..].to_vec(), true)
}
_ => {
positional += 1;
(positional.to_string(), part.children.clone(), false)
}
};
if args.contains_key(&key) {
self.warnings.emit(Warning::DuplicateArgument {
template: title.text(),
name: key.clone(),
});
}
args.insert(
key,
BoundArg {
dom: dom_idx,
nodes,
frame: caller,
trim,
},
);
}
self.frames.push(Frame { title, args });
self.frames.len() - 1
}
fn expand_nodes_owned(&mut self, dom_idx: usize, nodes: &[PpNodeId], frame: usize) -> String {
let owned = nodes.to_vec();
let mut out = String::new();
self.expand_nodes(dom_idx, &owned, frame, &mut out);
out
}
fn template_dom(&mut self, title: &Title) -> Option<usize> {
if let Some(cached) = self.template_doms.get(title) {
return *cached;
}
let resolved = match self.ctx.source.fetch(title) {
TemplateFetch::Found(text) => {
let (src, _) = self.store.push(&text, self.limits.max_source_bytes());
let dom = build(
self.store,
src,
self.config,
InclusionMode::Transcluded,
self.warnings,
);
self.doms.push(Rc::new(dom));
Some(self.doms.len() - 1)
}
TemplateFetch::Missing => None,
TemplateFetch::Unknown => {
if self.ctx.policy == UnresolvedPolicy::Halt
&& self.missing_set.insert(title.clone())
{
self.missing.push(title.clone());
}
None
}
};
self.template_doms.insert(title.clone(), resolved);
resolved
}
fn emit_red_link(&mut self, title: &Title, out: &mut String) {
out.push_str("[[");
out.push_str(&title.text());
out.push_str("]]");
}
fn record_used(&mut self, title: Title) {
if self.used_set.insert(title.clone()) {
self.used.push(title);
}
}
fn record_wanted(&mut self, title: Title) {
if self.wanted_set.insert(title.clone()) {
self.wanted.push(title);
}
}
fn charge_node(&mut self) -> bool {
self.node_count += 1;
if self.node_count > self.limits.node_count() {
if !self.hit_nodes {
self.hit_nodes = true;
self.warnings.emit(Warning::NodeCountExceeded {
limit: self.limits.node_count(),
});
}
return false;
}
true
}
fn charge_post_expand(&mut self, bytes: usize) {
self.post_expand = self.post_expand.saturating_add(bytes);
if self.post_expand > self.limits.post_expand_bytes() && !self.hit_size {
self.hit_size = true;
self.warnings.emit(Warning::PostExpandSizeExceeded {
limit: self.limits.post_expand_bytes(),
});
}
}
fn charge_expensive(&mut self) -> bool {
self.expensive += 1;
if self.expensive > self.limits.expensive_parser_functions() {
if !self.hit_expensive {
self.hit_expensive = true;
self.warnings.emit(Warning::ExpensiveFunctionLimit {
limit: self.limits.expensive_parser_functions(),
});
}
return false;
}
true
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{ExtTagContent, MagicWordDef, NamespaceDef};
use crate::source::{
EmptyTemplateSource, ExistenceMap, ExistenceProvider, ExistenceRecord,
InMemoryTemplateStore, ProviderError,
};
fn config() -> SiteConfig {
SiteConfig::builder("en", "Test Wiki")
.namespace(NamespaceDef::main())
.namespace(NamespaceDef::new(10, "Template", "Template", ["T"], true))
.namespace(NamespaceDef::new(
14,
"Category",
"Category",
Vec::<String>::new(),
true,
))
.namespace(NamespaceDef::new(
828,
"Module",
"Module",
Vec::<String>::new(),
true,
))
.magic_word(MagicWordDef::new("pagename", ["PAGENAME"], true))
.magic_word(MagicWordDef::new("fullpagename", ["FULLPAGENAME"], true))
.magic_word(MagicWordDef::new("sitename", ["SITENAME"], true))
.magic_word(MagicWordDef::new("!", ["!"], true))
.magic_word(MagicWordDef::new("=", ["="], true))
.magic_word(MagicWordDef::new("if", ["if"], false))
.magic_word(MagicWordDef::new("ifeq", ["ifeq"], false))
.magic_word(MagicWordDef::new("ifexpr", ["ifexpr"], false))
.magic_word(MagicWordDef::new("iferror", ["iferror"], false))
.magic_word(MagicWordDef::new("ifexist", ["ifexist"], false))
.magic_word(MagicWordDef::new("switch", ["switch"], false))
.magic_word(MagicWordDef::new("expr", ["expr"], false))
.magic_word(MagicWordDef::new("tag", ["tag"], false))
.magic_word(MagicWordDef::new("invoke", ["invoke"], false))
.magic_word(MagicWordDef::new("uc", ["uc:"], false))
.magic_word(MagicWordDef::new("lc", ["lc:"], false))
.magic_word(MagicWordDef::new("defaultsort", ["DEFAULTSORT:"], true))
.magic_word(MagicWordDef::new("padleft", ["padleft:"], false))
.magic_word(MagicWordDef::new("padright", ["padright:"], false))
.magic_word(MagicWordDef::new("urlencode", ["urlencode:"], false))
.magic_word(MagicWordDef::new("anchorencode", ["anchorencode:"], false))
.magic_word(MagicWordDef::new("lcfirst", ["lcfirst:"], false))
.magic_word(MagicWordDef::new("ucfirst", ["ucfirst:"], false))
.magic_word(MagicWordDef::new("ns", ["ns:"], false))
.magic_word(MagicWordDef::new("formatnum", ["formatnum:"], false))
.magic_word(MagicWordDef::new("displaytitle", ["DISPLAYTITLE:"], false))
.magic_word(MagicWordDef::new("basepagename", ["BASEPAGENAME"], true))
.magic_word(MagicWordDef::new("subpagename", ["SUBPAGENAME"], true))
.magic_word(MagicWordDef::new("rootpagename", ["ROOTPAGENAME"], true))
.magic_word(MagicWordDef::new("namespace", ["NAMESPACE"], true))
.magic_word(MagicWordDef::new(
"namespacenumber",
["NAMESPACENUMBER"],
true,
))
.magic_word(MagicWordDef::new("pagenamee", ["PAGENAMEE"], true))
.magic_word(MagicWordDef::new(
"contentlanguage",
["CONTENTLANGUAGE"],
true,
))
.magic_word(MagicWordDef::new("pagelanguage", ["PAGELANGUAGE"], true))
.magic_word(MagicWordDef::new("currentyear", ["CURRENTYEAR"], true))
.magic_word(MagicWordDef::new("currentmonth", ["CURRENTMONTH"], true))
.magic_word(MagicWordDef::new("currentmonth1", ["CURRENTMONTH1"], true))
.magic_word(MagicWordDef::new("currentmonth2", ["CURRENTMONTH2"], true))
.magic_word(MagicWordDef::new(
"currentmonthname",
["CURRENTMONTHNAME"],
true,
))
.magic_word(MagicWordDef::new(
"currentmonthabbrev",
["CURRENTMONTHABBREV"],
true,
))
.magic_word(MagicWordDef::new("currentday", ["CURRENTDAY"], true))
.magic_word(MagicWordDef::new("currentday2", ["CURRENTDAY2"], true))
.magic_word(MagicWordDef::new("currentdow", ["CURRENTDOW"], true))
.magic_word(MagicWordDef::new(
"currentdayname",
["CURRENTDAYNAME"],
true,
))
.magic_word(MagicWordDef::new("currenttime", ["CURRENTTIME"], true))
.magic_word(MagicWordDef::new("currenthour", ["CURRENTHOUR"], true))
.magic_word(MagicWordDef::new(
"currenttimestamp",
["CURRENTTIMESTAMP"],
true,
))
.magic_word(MagicWordDef::new("currentweek", ["CURRENTWEEK"], true))
.ext_tag("ref", ExtTagContent::InlineWikitext)
.ext_tag("nowiki", ExtTagContent::Opaque)
.build()
.unwrap()
}
fn title(text: &str) -> Title {
Title::parse(text, &config()).unwrap()
}
fn run_with(
store: &InMemoryTemplateStore,
wikitext: &str,
) -> (String, Expansion, Vec<Warning>) {
run_full(store, None, UnresolvedPolicy::RedLink, wikitext)
}
fn run_full(
source: &dyn TemplateSource,
existence: Option<&dyn ExistenceProvider>,
policy: UnresolvedPolicy,
wikitext: &str,
) -> (String, Expansion, Vec<Warning>) {
let cfg = config();
let limits = Limits::default();
let mut store = SourceStore::new();
let (src, _) = store.push(wikitext, limits.max_source_bytes());
let mut warnings = WarningCollector::new();
let page_dom = build(&store, src, &cfg, InclusionMode::TopLevel, &mut warnings);
let ctx = ExpandContext {
title: title("Sandbox"),
now: Timestamp::from_unix(946_684_800), source,
existence,
policy,
};
let mut strips = StripState::new();
let expansion = expand(
&mut store,
page_dom,
&cfg,
&limits,
&ctx,
&mut strips,
&mut warnings,
);
let text = expansion.text.clone();
(text, expansion, warnings.into_vec())
}
fn expand_text(wikitext: &str) -> String {
run_full(
&EmptyTemplateSource,
None,
UnresolvedPolicy::RedLink,
wikitext,
)
.0
}
#[test]
fn plain_text_passes_through_and_comments_vanish() {
assert_eq!(expand_text("hello <!--x--> world"), "hello world");
}
#[test]
fn basic_transclusion_substitutes_positional_arguments() {
let store = InMemoryTemplateStore::new();
store.insert(title("Template:Greet"), "Hello {{{1}}}!");
let (text, expansion, warnings) = run_with(&store, "{{Greet|World}}");
assert_eq!(text, "Hello World!");
assert_eq!(
expansion.used.iter().map(Title::text).collect::<Vec<_>>(),
["Template:Greet"]
);
assert!(warnings.is_empty());
}
#[test]
fn named_and_default_arguments_follow_whitespace_rules() {
let store = InMemoryTemplateStore::new();
store.insert(
title("Template:Box"),
"[{{{name|anon}}}][{{{1}}}][{{{miss|fallback}}}]",
);
let (text, _, _) = run_with(&store, "{{Box| name = Ann | spaced }}");
assert_eq!(text, "[Ann][ spaced ][fallback]");
}
#[test]
fn arguments_expand_in_the_caller_frame_not_the_callee() {
let store = InMemoryTemplateStore::new();
store.insert(title("Template:Inner"), "[{{{1}}}]");
store.insert(title("Template:Outer"), "{{Inner|{{{1}}}}}");
let (text, _, _) = run_with(&store, "{{Outer|outerval}}");
assert_eq!(text, "[outerval]");
}
#[test]
fn nested_argument_defaults_expand_in_their_own_frame() {
let store = InMemoryTemplateStore::new();
store.insert(title("Template:T"), "{{{a|{{{b|deep}}}}}}");
assert_eq!(run_with(&store, "{{T}}").0, "deep");
assert_eq!(run_with(&store, "{{T|b=mid}}").0, "mid");
assert_eq!(run_with(&store, "{{T|a=top}}").0, "top");
}
#[test]
fn transclusion_closure_is_recursive_and_first_encounter_ordered() {
let store = InMemoryTemplateStore::new();
store.insert(title("Template:A"), "{{B}}{{C}}");
store.insert(title("Template:B"), "{{C}}b");
store.insert(title("Template:C"), "c");
let (text, expansion, _) = run_with(&store, "{{A}}");
assert_eq!(text, "cbc");
assert_eq!(
expansion.used.iter().map(Title::text).collect::<Vec<_>>(),
["Template:A", "Template:B", "Template:C"]
);
}
#[test]
fn red_linked_template_renders_as_a_link_and_joins_the_closure() {
let store = InMemoryTemplateStore::new();
store.insert_missing(title("Template:Gone"));
let (text, expansion, _) = run_with(&store, "x{{Gone}}y");
assert_eq!(text, "x[[Template:Gone]]y");
assert_eq!(
expansion.used.iter().map(Title::text).collect::<Vec<_>>(),
["Template:Gone"]
);
}
#[test]
fn template_loop_is_detected_and_warns() {
let store = InMemoryTemplateStore::new();
store.insert(title("Template:A"), "a{{B}}");
store.insert(title("Template:B"), "b{{A}}");
let (text, _, warnings) = run_with(&store, "{{A}}");
assert_eq!(text, "ab[[Template:A]]");
assert!(
warnings
.iter()
.any(|w| matches!(w, Warning::TemplateLoop { .. })),
"{warnings:?}"
);
}
#[test]
fn if_switch_ifeq_ifexpr_select_the_right_branch() {
assert_eq!(expand_text("{{#if: x |yes|no}}"), "yes");
assert_eq!(expand_text("{{#if: |yes|no}}"), "no");
assert_eq!(expand_text("{{#ifeq:01|1|equal|diff}}"), "equal");
assert_eq!(expand_text("{{#ifeq:a|b|equal|diff}}"), "diff");
assert_eq!(expand_text("{{#switch:b|a=1|b=2|c=3|#default=x}}"), "2");
assert_eq!(expand_text("{{#switch:z|a=1|b=2|#default=x}}"), "x");
assert_eq!(expand_text("{{#switch:b|a|b|c=hit|#default=x}}"), "hit");
}
#[test]
fn ifexist_consults_the_provider_or_takes_the_false_branch() {
struct Provider;
impl ExistenceProvider for Provider {
fn lookup(&self, titles: &[Title]) -> Result<ExistenceMap, ProviderError> {
let mut map = ExistenceMap::new();
for t in titles {
map.insert(
t.clone(),
ExistenceRecord {
exists: t.dbkey() == "Real",
hidden_category: None,
},
);
}
Ok(map)
}
}
let (yes, _, _) = run_full(
&EmptyTemplateSource,
Some(&Provider),
UnresolvedPolicy::RedLink,
"{{#ifexist:Real|here|gone}}",
);
assert_eq!(yes, "here");
let (no, _, _) = run_full(
&EmptyTemplateSource,
Some(&Provider),
UnresolvedPolicy::RedLink,
"{{#ifexist:Nope|here|gone}}",
);
assert_eq!(no, "gone");
let (fallback, expansion, warnings) = run_with(
&InMemoryTemplateStore::new(),
"{{#ifexist:Wanted|here|gone}}",
);
assert_eq!(fallback, "gone");
assert_eq!(
expansion
.wanted_existence
.iter()
.map(Title::text)
.collect::<Vec<_>>(),
["Wanted"]
);
assert!(
warnings
.iter()
.any(|w| matches!(w, Warning::ExistenceUnknown { .. }))
);
}
#[test]
fn magic_words_and_variables_resolve() {
assert_eq!(expand_text("{{PAGENAME}}"), "Sandbox");
assert_eq!(expand_text("{{FULLPAGENAME}}"), "Sandbox");
assert_eq!(expand_text("{{SITENAME}}"), "Test Wiki");
assert_eq!(expand_text("a{{!}}b{{=}}c"), "a|b=c");
assert_eq!(expand_text("{{uc:hello}}{{lc:WORLD}}"), "HELLOworld");
}
#[test]
fn defaultsort_is_captured_as_a_side_effect() {
let (text, expansion, _) = run_with(&InMemoryTemplateStore::new(), "{{DEFAULTSORT:Key}}x");
assert_eq!(text, "x");
assert_eq!(expansion.default_sort.as_deref(), Some("Key"));
}
#[test]
fn tag_builds_an_extension_marker_with_expanded_body() {
let (text, _, _) = run_with(&InMemoryTemplateStore::new(), "a{{#tag:ref|body}}b");
assert!(text.starts_with('a') && text.ends_with('b'), "{text:?}");
assert!(
text.contains('\u{7f}'),
"the tag body is captured behind a strip marker: {text:?}"
);
}
#[test]
fn invoke_records_the_module_but_expands_to_nothing() {
let (text, expansion, warnings) =
run_with(&InMemoryTemplateStore::new(), "x{{#invoke:Str|len|hi}}y");
assert_eq!(text, "xy");
assert_eq!(
expansion.used.iter().map(Title::text).collect::<Vec<_>>(),
["Module:Str"]
);
assert_eq!(expansion.modules.len(), 1);
assert_eq!(expansion.modules[0].module().text(), "Module:Str");
assert_eq!(expansion.modules[0].function(), "len");
assert_eq!(
expansion.modules[0].args().get("1").map(String::as_str),
Some("hi")
);
assert!(
warnings
.iter()
.any(|w| matches!(w, Warning::UnsupportedModuleInvocation { .. }))
);
}
#[test]
fn duplicate_named_argument_warns_and_keeps_the_last() {
let store = InMemoryTemplateStore::new();
store.insert(title("Template:T"), "{{{k}}}");
let (text, _, warnings) = run_with(&store, "{{T|k=1|k=2}}");
assert_eq!(text, "2");
assert!(
warnings
.iter()
.any(|w| matches!(w, Warning::DuplicateArgument { .. }))
);
}
#[test]
fn depth_limit_stops_runaway_recursion_and_warns() {
let store = InMemoryTemplateStore::new();
store.insert(title("Template:R"), "{{R}}");
let cfg = config();
let limits = Limits::default().with_expansion_depth(5);
let mut store2 = SourceStore::new();
let (src, _) = store2.push("{{R}}", limits.max_source_bytes());
let mut warnings = WarningCollector::new();
let dom = build(&store2, src, &cfg, InclusionMode::TopLevel, &mut warnings);
let ctx = ExpandContext {
title: title("Sandbox"),
now: Timestamp::from_unix(0),
source: &store,
existence: None,
policy: UnresolvedPolicy::RedLink,
};
let mut strips = StripState::new();
let _ = expand(
&mut store2,
dom,
&cfg,
&limits,
&ctx,
&mut strips,
&mut warnings,
);
assert!(warnings.into_vec().iter().any(|w| matches!(
w,
Warning::TemplateLoop { .. } | Warning::ExpansionDepthExceeded { .. }
)));
}
#[test]
fn halt_policy_reports_unknown_templates() {
let store = InMemoryTemplateStore::new(); let (text, expansion, _) =
run_full(&store, None, UnresolvedPolicy::Halt, "{{NeedFetch|x}}");
assert_eq!(text, "[[Template:NeedFetch]]");
assert_eq!(
expansion
.missing
.iter()
.map(Title::text)
.collect::<Vec<_>>(),
["Template:NeedFetch"]
);
}
#[test]
fn unknown_template_under_redlink_policy_is_a_red_link() {
let (text, expansion, _) = run_full(
&EmptyTemplateSource,
None,
UnresolvedPolicy::RedLink,
"{{Nope}}",
);
assert_eq!(text, "[[Template:Nope]]");
assert!(expansion.missing.is_empty());
}
#[test]
fn includeonly_and_noinclude_apply_in_transcluded_context() {
let store = InMemoryTemplateStore::new();
store.insert(
title("Template:T"),
"<includeonly>IN</includeonly><noinclude>OUT</noinclude>",
);
assert_eq!(run_with(&store, "{{T}}").0, "IN");
}
#[test]
fn dynamic_template_name_is_built_by_expansion() {
let store = InMemoryTemplateStore::new();
store.insert(title("Template:Foo"), "resolved");
assert_eq!(run_with(&store, "{{{{{which|Foo}}}}}").0, "resolved");
}
#[test]
fn unsupported_parser_function_warns_and_stays_literal() {
let (text, _, warnings) = run_with(&InMemoryTemplateStore::new(), "{{#nope:arg}}");
assert_eq!(text, "{{#nope:arg}}");
assert!(
warnings
.iter()
.any(|w| matches!(w, Warning::UnsupportedParserFunction { .. }))
);
}
#[test]
fn extension_tags_survive_expansion_as_markers() {
let (text, _, _) = run_with(&InMemoryTemplateStore::new(), "a<nowiki>{{x}}</nowiki>b");
assert!(text.starts_with('a') && text.ends_with('b'));
assert!(
text.contains('\u{7f}'),
"nowiki captured as a marker: {text:?}"
);
}
fn run_titled(page_title: &str, wikitext: &str) -> String {
let cfg = config();
let limits = Limits::default();
let mut store = SourceStore::new();
let (src, _) = store.push(wikitext, limits.max_source_bytes());
let mut warnings = WarningCollector::new();
let page_dom = build(&store, src, &cfg, InclusionMode::TopLevel, &mut warnings);
let ctx = ExpandContext {
title: title(page_title),
now: Timestamp::from_unix(946_684_800),
source: &EmptyTemplateSource,
existence: None,
policy: UnresolvedPolicy::RedLink,
};
let mut strips = StripState::new();
expand(
&mut store,
page_dom,
&cfg,
&limits,
&ctx,
&mut strips,
&mut warnings,
)
.text
}
#[test]
fn subpage_variables_split_on_slashes() {
assert_eq!(run_titled("Root/Sub/Leaf", "{{BASEPAGENAME}}"), "Root/Sub");
assert_eq!(run_titled("Root/Sub/Leaf", "{{SUBPAGENAME}}"), "Leaf");
assert_eq!(run_titled("Root/Sub/Leaf", "{{ROOTPAGENAME}}"), "Root");
assert_eq!(run_titled("Flat", "{{BASEPAGENAME}}"), "Flat");
assert_eq!(run_titled("Flat", "{{SUBPAGENAME}}"), "Flat");
assert_eq!(run_titled("Flat", "{{ROOTPAGENAME}}"), "Flat");
}
#[test]
fn current_variables_format_the_frozen_now() {
assert_eq!(expand_text("{{CURRENTYEAR}}"), "2000");
assert_eq!(expand_text("{{CURRENTMONTH}}"), "01");
let all = expand_text(concat!(
"{{CURRENTMONTH1}}|{{CURRENTMONTH2}}|{{CURRENTMONTHNAME}}|",
"{{CURRENTMONTHABBREV}}|{{CURRENTDAY}}|{{CURRENTDAY2}}|{{CURRENTDOW}}|",
"{{CURRENTDAYNAME}}|{{CURRENTTIME}}|{{CURRENTHOUR}}|{{CURRENTTIMESTAMP}}|",
"{{CURRENTWEEK}}"
));
assert!(all.contains("20000101000000"), "{all:?}");
}
#[test]
fn namespace_and_language_variables_resolve() {
assert_eq!(run_titled("Template:Doc", "{{NAMESPACE}}"), "Template");
assert_eq!(run_titled("Template:Doc", "{{NAMESPACENUMBER}}"), "10");
assert_eq!(run_titled("A page", "{{PAGENAMEE}}"), "A_page");
assert_eq!(expand_text("{{CONTENTLANGUAGE}}"), "en");
assert_eq!(expand_text("{{PAGELANGUAGE}}"), "en");
}
#[test]
fn colon_variable_takes_a_page_argument() {
assert_eq!(expand_text("{{PAGENAME:Foo bar}}"), "Foo bar");
assert_eq!(expand_text("{{NAMESPACE:Template:X}}"), "Template");
}
#[test]
fn colon_string_functions_transform_their_argument() {
assert_eq!(expand_text("{{padleft:7|3|xy}}"), "xy7");
assert_eq!(expand_text("{{padright:7|3|-}}"), "7--");
assert_eq!(expand_text("{{urlencode:a b}}"), "a+b");
assert_eq!(expand_text("{{urlencode:a b|WIKI}}"), "a_b");
assert_eq!(expand_text("{{anchorencode:a b}}"), "a_b");
assert_eq!(expand_text("{{lcfirst:Hello}}"), "hello");
assert_eq!(expand_text("{{ucfirst:hello}}"), "Hello");
assert_eq!(expand_text("{{formatnum:1234}}"), "1234");
}
#[test]
fn padleft_uses_the_default_pad_when_the_argument_is_absent() {
assert_eq!(expand_text("{{padleft:7|3}}"), "007");
}
#[test]
fn ns_function_resolves_by_id_and_name() {
assert_eq!(expand_text("{{ns:10}}"), "Template");
assert_eq!(expand_text("{{ns:Category}}"), "Category");
assert_eq!(
expand_text("x{{ns:999}}y"),
"xy",
"unknown id yields nothing"
);
}
#[test]
fn displaytitle_is_captured_as_a_side_effect() {
let (text, expansion, _) =
run_with(&InMemoryTemplateStore::new(), "{{DISPLAYTITLE:New Title}}z");
assert_eq!(text, "z");
assert_eq!(expansion.display_title.as_deref(), Some("New Title"));
}
#[test]
fn iferror_selects_each_branch_and_the_single_argument_form() {
assert_eq!(expand_text("{{#iferror:hello}}"), "hello");
assert_eq!(expand_text("{{#iferror:hello|caught|fine}}"), "fine");
assert_eq!(
expand_text("{{#iferror:x class=\"error\" y|caught|fine}}"),
"caught"
);
}
#[test]
fn expr_and_ifexpr_render_their_error_messages() {
assert!(expand_text("{{#expr:1+}}").contains("Expression error"));
assert!(expand_text("{{#ifexpr:1+|a|b}}").contains("Expression error"));
assert_eq!(expand_text("{{#ifexpr:2-2|a|b}}"), "b", "zero is falsy");
assert_eq!(expand_text("{{#ifexpr:|a|b}}"), "b", "empty is falsy");
assert_eq!(expand_text("{{#ifexpr:3|a|b}}"), "a", "nonzero is truthy");
}
#[test]
fn switch_falls_through_and_takes_a_bare_default() {
assert_eq!(expand_text("{{#switch:a|a|z}}"), "z");
assert_eq!(expand_text("{{#switch:q|a|b|deftext}}"), "deftext");
}
#[test]
fn time_formats_and_parses_timestamp_specs() {
assert_eq!(expand_text("{{#time:Y}}"), "2000", "empty spec uses now");
assert_eq!(
expand_text("{{#time:Y|@1000000000}}"),
"2001",
"@seconds is absolute"
);
assert_eq!(
expand_text("{{#time:Y|garbage}}"),
"2000",
"unparseable spec falls back to now"
);
let (_, _, warnings) = run_with(&InMemoryTemplateStore::new(), "{{#time:e}}");
assert!(
warnings
.iter()
.any(|w| matches!(w, Warning::UnsupportedTimeFormat)),
"{warnings:?}"
);
}
#[test]
fn tag_builds_attributes_and_ignores_an_empty_name() {
assert_eq!(
expand_text("{{#tag:}}"),
"",
"empty tag name renders nothing"
);
let (text, _, _) = run_with(
&InMemoryTemplateStore::new(),
"{{#tag:ref|body|group=note}}",
);
assert!(
text.contains('\u{7f}'),
"tag becomes a strip marker: {text:?}"
);
}
#[test]
fn invoke_records_named_arguments_and_skips_an_empty_module() {
let (_, expansion, _) =
run_with(&InMemoryTemplateStore::new(), "{{#invoke:Mod|fn|k=v|pos}}");
assert_eq!(expansion.modules.len(), 1);
assert_eq!(
expansion.modules[0].args().get("k").map(String::as_str),
Some("v")
);
assert_eq!(
expansion.modules[0].args().get("1").map(String::as_str),
Some("pos")
);
let (text, expansion, _) = run_with(&InMemoryTemplateStore::new(), "x{{#invoke:}}y");
assert_eq!(text, "xy");
assert!(expansion.modules.is_empty());
}
#[test]
fn dispatch_edge_cases_for_colons_variables_and_titles() {
assert_eq!(expand_text("{{Category:Cats}}"), "[[Category:Cats]]");
assert_eq!(expand_text("a{{if}}b"), "ab");
assert_eq!(expand_text("{{~~~}}"), "{{~~~}}");
assert_eq!(expand_text("a{{#if}}b"), "ab");
let store = InMemoryTemplateStore::new();
store.insert(title("Template:Foo"), "resolved");
assert_eq!(run_with(&store, "{{subst:Foo}}").0, "resolved");
}
#[test]
fn ifexist_charges_the_expensive_budget() {
let cfg = config();
let limits = Limits::default().with_expensive_parser_functions(0);
let mut store = SourceStore::new();
let (src, _) = store.push("{{#ifexist:Somewhere|yes|no}}", limits.max_source_bytes());
let mut warnings = WarningCollector::new();
let dom = build(&store, src, &cfg, InclusionMode::TopLevel, &mut warnings);
let ctx = ExpandContext {
title: title("Sandbox"),
now: Timestamp::from_unix(0),
source: &EmptyTemplateSource,
existence: None,
policy: UnresolvedPolicy::RedLink,
};
let mut strips = StripState::new();
let out = expand(
&mut store,
dom,
&cfg,
&limits,
&ctx,
&mut strips,
&mut warnings,
);
assert_eq!(out.text, "no");
}
fn run_limited(
source: &dyn TemplateSource,
limits: &Limits,
wikitext: &str,
) -> (String, Vec<Warning>) {
let cfg = config();
let mut store = SourceStore::new();
let (src, _) = store.push(wikitext, limits.max_source_bytes());
let mut warnings = WarningCollector::new();
let dom = build(&store, src, &cfg, InclusionMode::TopLevel, &mut warnings);
let ctx = ExpandContext {
title: title("Sandbox"),
now: Timestamp::from_unix(0),
source,
existence: None,
policy: UnresolvedPolicy::RedLink,
};
let mut strips = StripState::new();
let out = expand(
&mut store,
dom,
&cfg,
limits,
&ctx,
&mut strips,
&mut warnings,
);
(out.text, warnings.into_vec())
}
#[test]
fn node_budget_exhaustion_emits_raw_text_and_warns() {
let (text, warnings) = run_limited(
&EmptyTemplateSource,
&Limits::default().with_node_count(1),
"a{{b}}",
);
assert!(text.starts_with('a'), "{text:?}");
assert!(
warnings
.iter()
.any(|w| matches!(w, Warning::NodeCountExceeded { .. })),
"{warnings:?}"
);
}
#[test]
fn expansion_depth_limit_degrades_to_a_red_link() {
let store = InMemoryTemplateStore::new();
store.insert(title("Template:A"), "{{B}}");
store.insert(title("Template:B"), "{{C}}");
store.insert(title("Template:C"), "{{D}}");
store.insert(title("Template:D"), "deep");
let (_, warnings) =
run_limited(&store, &Limits::default().with_expansion_depth(2), "{{A}}");
assert!(
warnings
.iter()
.any(|w| matches!(w, Warning::ExpansionDepthExceeded { .. })),
"{warnings:?}"
);
}
#[test]
fn post_expand_size_limit_warns() {
let store = InMemoryTemplateStore::new();
store.insert(title("Template:T"), "hello");
let (_, warnings) = run_limited(
&store,
&Limits::default().with_post_expand_bytes(1),
"{{T}}",
);
assert!(
warnings
.iter()
.any(|w| matches!(w, Warning::PostExpandSizeExceeded { .. })),
"{warnings:?}"
);
}
#[test]
fn unclosed_extension_tag_warns() {
let (_, _, warnings) = run_with(&InMemoryTemplateStore::new(), "a<ref>body");
assert!(
warnings
.iter()
.any(|w| matches!(w, Warning::UnclosedExtensionTag { .. })),
"{warnings:?}"
);
}
#[test]
fn empty_and_unbound_names_fall_back_to_raw_source() {
assert_eq!(expand_text("{{ }}"), "{{ }}");
assert_eq!(
expand_text("{{<nowiki>x</nowiki>}}"),
"{{<nowiki>x</nowiki>}}"
);
assert_eq!(expand_text("{{{unbound}}}"), "{{{unbound}}}");
}
#[test]
fn ifexist_with_an_invalid_title_takes_the_false_branch() {
assert_eq!(expand_text("{{#ifexist:~~~|yes|no}}"), "no");
}
#[test]
fn time_spec_with_an_unparseable_at_prefix_falls_back_to_now() {
assert_eq!(expand_text("{{#time:Y|@notanumber}}"), "2000");
}
#[test]
fn a_partless_template_or_argument_node_expands_to_nothing() {
use crate::engine::pp::{PpKind, PpNode};
let cfg = config();
let limits = Limits::default();
let mut store = SourceStore::new();
let (src, _) = store.push("x", limits.max_source_bytes());
let empty = Span::new(src, 0, 0);
let mut dom = PpDom::new();
let tpl = dom.push(PpNode {
kind: PpKind::Template { parts: Vec::new() },
span: empty,
});
dom.push_root(tpl);
let arg = dom.push(PpNode {
kind: PpKind::TplArg { parts: Vec::new() },
span: empty,
});
dom.push_root(arg);
let ctx = ExpandContext {
title: title("Sandbox"),
now: Timestamp::from_unix(0),
source: &EmptyTemplateSource,
existence: None,
policy: UnresolvedPolicy::RedLink,
};
let mut strips = StripState::new();
let mut warnings = WarningCollector::new();
let out = expand(
&mut store,
dom,
&cfg,
&limits,
&ctx,
&mut strips,
&mut warnings,
);
assert_eq!(out.text, "");
}
}