use crate::config::SiteConfig;
use crate::engine::source::{SourceId, SourceStore, Span};
use crate::warn::WarningCollector;
use super::{Part, PpDom, PpKind, PpNode, PpNodeId};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum InclusionMode {
TopLevel,
Transcluded,
}
pub(crate) fn build(
store: &SourceStore,
src: SourceId,
config: &SiteConfig,
mode: InclusionMode,
_warnings: &mut WarningCollector,
) -> PpDom {
Scanner::new(store.text(src), src, config, mode).run()
}
#[derive(Debug, Clone, Copy)]
enum Child {
Text(usize, usize),
Node(PpNodeId),
}
#[derive(Debug)]
struct PartAcc {
children: Vec<Child>,
eq: Option<usize>,
start: usize,
end: usize,
}
impl PartAcc {
fn new(start: usize) -> Self {
Self {
children: Vec::new(),
eq: None,
start,
end: start,
}
}
}
#[derive(Debug)]
struct OpenPiece {
open: usize,
count: usize,
brackets: usize,
done: Vec<PartAcc>,
cur: PartAcc,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Directive {
IncludeOnly,
NoInclude,
OnlyInclude,
}
impl Directive {
fn from_name(name: &str) -> Option<Self> {
if name.eq_ignore_ascii_case("includeonly") {
Some(Self::IncludeOnly)
} else if name.eq_ignore_ascii_case("noinclude") {
Some(Self::NoInclude)
} else if name.eq_ignore_ascii_case("onlyinclude") {
Some(Self::OnlyInclude)
} else {
None
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DirectiveAction {
Region,
Tag,
Literal,
}
#[derive(Debug, Clone, Copy)]
struct TagHead {
closing: bool,
name_start: usize,
name_end: usize,
}
const ONLYINCLUDE_OPEN: &str = "<onlyinclude>";
const ONLYINCLUDE_CLOSE: &str = "</onlyinclude>";
struct Scanner<'a> {
text: &'a str,
src: SourceId,
config: &'a SiteConfig,
mode: InclusionMode,
dom: PpDom,
root: Vec<Child>,
stack: Vec<OpenPiece>,
lit_start: usize,
pos: usize,
only: bool,
}
impl<'a> Scanner<'a> {
fn new(text: &'a str, src: SourceId, config: &'a SiteConfig, mode: InclusionMode) -> Self {
Self {
text,
src,
config,
mode,
dom: PpDom::new(),
root: Vec::new(),
stack: Vec::new(),
lit_start: 0,
pos: 0,
only: mode == InclusionMode::Transcluded && text.contains(ONLYINCLUDE_OPEN),
}
}
fn run(mut self) -> PpDom {
if self.only {
self.resume_onlyinclude(0);
}
while self.pos < self.text.len() {
match self.text.as_bytes()[self.pos] {
b'{' => self.open_braces(),
b'}' => self.close_braces(),
b'[' => self.open_bracket(),
b']' => self.close_bracket(),
b'|' => self.split_part(),
b'=' => self.maybe_equals(),
b'<' => self.angle(),
_ => self.pos += 1,
}
}
self.flush_text();
self.unwind();
let root = std::mem::take(&mut self.root);
let (ids, _) = materialize(&mut self.dom, self.src, root, None);
for id in ids {
self.dom.push_root(id);
}
self.dom
}
fn accum(&mut self) -> &mut Vec<Child> {
match self.stack.last_mut() {
Some(piece) => &mut piece.cur.children,
None => &mut self.root,
}
}
fn flush_text(&mut self) {
if self.pos > self.lit_start {
let (start, end) = (self.lit_start, self.pos);
self.accum().push(Child::Text(start, end));
}
self.lit_start = self.pos;
}
fn emit_leaf(&mut self, kind: PpKind, start: usize, end: usize) {
let id = self.dom.push(PpNode {
kind,
span: span(self.src, start, end),
});
self.accum().push(Child::Node(id));
}
fn run_len(&self, byte: u8) -> usize {
self.text.as_bytes()[self.pos..]
.iter()
.take_while(|&&b| b == byte)
.count()
}
fn open_braces(&mut self) {
let run = self.run_len(b'{');
if run >= 2 {
self.flush_text();
self.stack.push(OpenPiece {
open: self.pos,
count: run,
brackets: 0,
done: Vec::new(),
cur: PartAcc::new(self.pos + run),
});
self.pos += run;
self.lit_start = self.pos;
} else {
self.pos += run;
}
}
fn close_braces(&mut self) {
let run = self.run_len(b'}');
let run_end = self.pos + run;
if run < 2 || self.stack.is_empty() {
self.pos = run_end;
return;
}
self.flush_text();
let mut cur = self.pos;
let mut remaining = run;
while remaining >= 2 {
let Some(piece) = self.stack.pop() else {
break;
};
let take = if piece.count >= 3 && remaining >= 3 {
3
} else {
2
};
self.close_top_piece(piece, take, cur);
cur += take;
remaining -= take;
}
self.lit_start = cur;
self.pos = run_end;
}
fn close_top_piece(&mut self, mut piece: OpenPiece, take: usize, cur: usize) {
let node_start = piece.open + piece.count - take;
let node_end = cur + take;
piece.cur.end = cur;
let mut parts_acc = std::mem::take(&mut piece.done);
parts_acc.push(std::mem::replace(&mut piece.cur, PartAcc::new(node_start)));
let kind = if take == 3 {
PpKind::TplArg {
parts: tplarg_parts(&mut self.dom, self.src, parts_acc),
}
} else {
PpKind::Template {
parts: template_parts(&mut self.dom, self.src, parts_acc),
}
};
let id = self.dom.push(PpNode {
kind,
span: span(self.src, node_start, node_end),
});
piece.count -= take;
if piece.count >= 2 {
piece.cur.children.push(Child::Node(id));
self.stack.push(piece);
} else {
if piece.count > 0 {
let leftover = Child::Text(piece.open, piece.open + piece.count);
self.accum().push(leftover);
}
self.accum().push(Child::Node(id));
}
}
fn open_bracket(&mut self) {
if let Some(piece) = self.stack.last_mut() {
piece.brackets += 1;
}
self.pos += 1;
}
fn close_bracket(&mut self) {
if let Some(piece) = self.stack.last_mut() {
piece.brackets = piece.brackets.saturating_sub(1);
}
self.pos += 1;
}
fn split_part(&mut self) {
let (lit_start, pos) = (self.lit_start, self.pos);
self.pos += 1;
if let Some(piece) = self.stack.last_mut() {
if piece.brackets > 0 {
return;
}
if pos > lit_start {
piece.cur.children.push(Child::Text(lit_start, pos));
}
piece.cur.end = pos;
let next = PartAcc::new(pos + 1);
piece.done.push(std::mem::replace(&mut piece.cur, next));
self.lit_start = self.pos;
}
}
fn maybe_equals(&mut self) {
let (lit_start, pos) = (self.lit_start, self.pos);
self.pos += 1;
if let Some(piece) = self.stack.last_mut() {
if piece.brackets == 0 && !piece.done.is_empty() && piece.cur.eq.is_none() {
if pos > lit_start {
piece.cur.children.push(Child::Text(lit_start, pos));
}
piece.cur.eq = Some(piece.cur.children.len());
piece.cur.children.push(Child::Text(pos, pos + 1));
self.lit_start = self.pos;
}
}
}
fn angle(&mut self) {
let rest = &self.text[self.pos..];
if rest.starts_with("<!--") {
self.comment();
return;
}
if self.only && rest.starts_with(ONLYINCLUDE_CLOSE) {
self.onlyinclude_close();
return;
}
if let Some(head) = self.parse_tag_head() {
let name = &self.text[head.name_start..head.name_end];
if let Some(directive) = Directive::from_name(name) {
if self.directive(head, directive) {
return;
}
} else if !head.closing && self.config.ext_tag(name).is_some() && self.ext_tag(head) {
return;
}
}
self.pos += 1;
}
fn comment(&mut self) {
let end = match self.text[self.pos + 4..].find("-->") {
Some(rel) => self.pos + 4 + rel + 3,
None => self.text.len(),
};
self.flush_text();
self.emit_leaf(PpKind::Comment, self.pos, end);
self.pos = end;
self.lit_start = end;
}
fn parse_tag_head(&self) -> Option<TagHead> {
let bytes = self.text.as_bytes();
let mut i = self.pos + 1;
let closing = bytes.get(i) == Some(&b'/');
if closing {
i += 1;
}
let name_start = i;
while i < bytes.len() && bytes[i].is_ascii_alphanumeric() {
i += 1;
}
if i == name_start {
return None;
}
match bytes.get(i) {
None | Some(b'>' | b'/') => {}
Some(b) if b.is_ascii_whitespace() => {}
Some(_) => return None,
}
Some(TagHead {
closing,
name_start,
name_end: i,
})
}
fn directive(&mut self, head: TagHead, directive: Directive) -> bool {
use DirectiveAction::{Literal, Region, Tag};
let action = match (self.mode, directive, head.closing) {
(InclusionMode::TopLevel, Directive::IncludeOnly, false) => Region,
(InclusionMode::TopLevel, Directive::IncludeOnly, true) => Literal,
(InclusionMode::TopLevel, Directive::NoInclude | Directive::OnlyInclude, _) => Tag,
(InclusionMode::Transcluded, Directive::NoInclude, false) => Region,
(InclusionMode::Transcluded, Directive::NoInclude, true) => Literal,
(InclusionMode::Transcluded, Directive::IncludeOnly, _) => Tag,
(InclusionMode::Transcluded, Directive::OnlyInclude, _) => Literal,
};
if action == Literal {
return false;
}
self.flush_text();
let Some(gt_rel) = self.text[head.name_end..].find('>') else {
let len = self.text.len();
self.emit_leaf(PpKind::Ignored, self.pos, len);
self.pos = len;
self.lit_start = len;
return true;
};
let gt = head.name_end + gt_rel;
let tag_end = gt + 1;
let self_closing = gt > head.name_end && self.text.as_bytes()[gt - 1] == b'/';
let name = &self.text[head.name_start..head.name_end];
let end = if action == Tag || self_closing {
tag_end
} else {
match find_close_tag(self.text, tag_end, name) {
Some((_, close_end)) => close_end,
None => self.text.len(),
}
};
self.emit_leaf(PpKind::Ignored, self.pos, end);
self.pos = end;
self.lit_start = end;
true
}
fn ext_tag(&mut self, head: TagHead) -> bool {
let Some(gt_rel) = self.text[head.name_end..].find('>') else {
return false;
};
let gt = head.name_end + gt_rel;
let self_closing = gt > head.name_end && self.text.as_bytes()[gt - 1] == b'/';
let attrs_end = if self_closing { gt - 1 } else { gt };
let attributes =
(attrs_end > head.name_end).then(|| span(self.src, head.name_end, attrs_end));
let name_span = span(self.src, head.name_start, head.name_end);
let name = &self.text[head.name_start..head.name_end];
let (body, node_end, closed) = if self_closing {
(None, gt + 1, true)
} else {
match find_close_tag(self.text, gt + 1, name) {
Some((close_start, close_end)) => {
(Some(span(self.src, gt + 1, close_start)), close_end, true)
}
None => (
Some(span(self.src, gt + 1, self.text.len())),
self.text.len(),
false,
),
}
};
self.flush_text();
let start = self.pos;
self.emit_leaf(
PpKind::ExtTag {
name: name_span,
attributes,
body,
closed,
},
start,
node_end,
);
self.pos = node_end;
self.lit_start = node_end;
true
}
fn onlyinclude_close(&mut self) {
self.flush_text();
let tag_end = self.pos + ONLYINCLUDE_CLOSE.len();
self.emit_leaf(PpKind::Ignored, self.pos, tag_end);
self.resume_onlyinclude(tag_end);
}
fn resume_onlyinclude(&mut self, from: usize) {
match self.text[from..].find(ONLYINCLUDE_OPEN) {
Some(rel) => {
let open = from + rel;
if open > from {
self.emit_leaf(PpKind::Ignored, from, open);
}
let content = open + ONLYINCLUDE_OPEN.len();
self.emit_leaf(PpKind::Ignored, open, content);
self.pos = content;
}
None => {
if from < self.text.len() {
self.emit_leaf(PpKind::Ignored, from, self.text.len());
}
self.pos = self.text.len();
}
}
self.lit_start = self.pos;
}
fn unwind(&mut self) {
while let Some(piece) = self.stack.pop() {
let mut items = vec![Child::Text(piece.open, piece.open + piece.count)];
let parts = piece.done.into_iter().chain(std::iter::once(piece.cur));
for (i, mut part) in parts.enumerate() {
if i > 0 {
items.push(Child::Text(part.start - 1, part.start));
}
items.append(&mut part.children);
}
self.accum().extend(items);
}
}
}
fn span(src: SourceId, start: usize, end: usize) -> Span {
let clamp = |v: usize| u32::try_from(v).unwrap_or(u32::MAX);
Span::new(src, clamp(start), clamp(end))
}
fn find_close_tag(text: &str, from: usize, name: &str) -> Option<(usize, usize)> {
let bytes = text.as_bytes();
let mut i = from;
loop {
let rel = text[i..].find('<')?;
let at = i + rel;
i = at + 1;
if bytes.get(at + 1) != Some(&b'/') {
continue;
}
let name_matches = text
.get(at + 2..at + 2 + name.len())
.is_some_and(|slice| slice.eq_ignore_ascii_case(name));
if !name_matches {
continue;
}
let mut j = at + 2 + name.len();
while j < bytes.len() && bytes[j].is_ascii_whitespace() {
j += 1;
}
if bytes.get(j) == Some(&b'>') {
return Some((at, j + 1));
}
}
}
fn text_node(dom: &mut PpDom, src: SourceId, start: usize, end: usize) -> PpNodeId {
dom.push(PpNode {
kind: PpKind::Text,
span: span(src, start, end),
})
}
fn materialize(
dom: &mut PpDom,
src: SourceId,
children: Vec<Child>,
eq: Option<usize>,
) -> (Vec<PpNodeId>, Option<usize>) {
let mut out = Vec::new();
let mut new_eq = None;
let mut pending: Option<(usize, usize)> = None;
for (i, child) in children.into_iter().enumerate() {
match child {
Child::Text(s, e) if Some(i) == eq => {
if let Some((ps, pe)) = pending.take() {
out.push(text_node(dom, src, ps, pe));
}
new_eq = Some(out.len());
out.push(text_node(dom, src, s, e));
}
Child::Text(s, e) => {
pending = match pending {
Some((ps, pe)) => {
debug_assert_eq!(pe, s);
Some((ps, e))
}
None => Some((s, e)),
};
}
Child::Node(id) => {
if let Some((s, e)) = pending.take() {
out.push(text_node(dom, src, s, e));
}
out.push(id);
}
}
}
if let Some((s, e)) = pending {
out.push(text_node(dom, src, s, e));
}
(out, new_eq)
}
fn template_parts(dom: &mut PpDom, src: SourceId, parts: Vec<PartAcc>) -> Vec<Part> {
parts
.into_iter()
.map(|acc| {
let part_span = span(src, acc.start, acc.end);
let (children, eq) = materialize(dom, src, acc.children, acc.eq);
Part {
children,
eq,
span: part_span,
}
})
.collect()
}
fn tplarg_parts(dom: &mut PpDom, src: SourceId, parts: Vec<PartAcc>) -> Vec<Part> {
let mut out = Vec::new();
let mut default: Option<PartAcc> = None;
for (index, mut extra) in parts.into_iter().enumerate() {
if index == 0 {
let part_span = span(src, extra.start, extra.end);
let (children, _) = materialize(dom, src, extra.children, None);
out.push(Part {
children,
eq: None,
span: part_span,
});
} else {
match default.as_mut() {
None => default = Some(extra),
Some(acc) => {
acc.children.push(Child::Text(extra.start - 1, extra.start));
acc.children.append(&mut extra.children);
acc.end = extra.end;
}
}
}
}
if let Some(acc) = default {
let part_span = span(src, acc.start, acc.end);
let (children, _) = materialize(dom, src, acc.children, None);
out.push(Part {
children,
eq: None,
span: part_span,
});
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{ExtTagContent, NamespaceDef};
fn cfg() -> SiteConfig {
SiteConfig::builder("en", "Test Wiki")
.namespace(NamespaceDef::main())
.namespace(NamespaceDef::new(
10,
"Template",
"Template",
Vec::<String>::new(),
true,
))
.ext_tag("nowiki", ExtTagContent::Opaque)
.ext_tag("pre", ExtTagContent::Opaque)
.ext_tag("ref", ExtTagContent::InlineWikitext)
.build()
.unwrap()
}
fn build_mode(input: &str, mode: InclusionMode) -> (SourceStore, PpDom) {
let mut store = SourceStore::new();
let (src, _) = store.push(input, 4 * 1024 * 1024);
let mut warnings = WarningCollector::new();
let dom = build(&store, src, &cfg(), mode, &mut warnings);
assert!(warnings.as_slice().is_empty(), "scanner never warns");
assert_tiles(&store, &dom, input);
(store, dom)
}
fn build_top(input: &str) -> (SourceStore, PpDom) {
build_mode(input, InclusionMode::TopLevel)
}
fn kind_name(kind: &PpKind) -> &'static str {
match kind {
PpKind::Text => "text",
PpKind::Comment => "comment",
PpKind::Ignored => "ignored",
PpKind::Template { .. } => "template",
PpKind::TplArg { .. } => "tplarg",
PpKind::ExtTag { .. } => "ext",
}
}
fn shape(store: &SourceStore, dom: &PpDom) -> Vec<(&'static str, String)> {
dom.root()
.iter()
.map(|&id| {
let node = dom.node(id);
(kind_name(&node.kind), store.slice(node.span).to_owned())
})
.collect()
}
fn row(kind: &'static str, text: &str) -> (&'static str, String) {
(kind, text.to_owned())
}
fn node_parts(dom: &PpDom, id: PpNodeId) -> Vec<Part> {
match &dom.node(id).kind {
PpKind::Template { parts } | PpKind::TplArg { parts } => parts.clone(),
_ => Vec::new(),
}
}
fn part_shape(store: &SourceStore, dom: &PpDom, part: &Part) -> Vec<(&'static str, String)> {
part.children
.iter()
.map(|&id| {
let node = dom.node(id);
(kind_name(&node.kind), store.slice(node.span).to_owned())
})
.collect()
}
fn assert_tiles(store: &SourceStore, dom: &PpDom, input: &str) {
let mut cursor = 0;
for &id in dom.root() {
let node_span = dom.node(id).span;
assert_eq!(node_span.start, cursor, "root gap in {input:?}");
cursor = node_span.end;
}
assert_eq!(cursor as usize, input.len(), "root cover in {input:?}");
let mut stack: Vec<PpNodeId> = dom.root().to_vec();
while let Some(id) = stack.pop() {
let node = dom.node(id);
let braces: u32 = match &node.kind {
PpKind::Template { .. } => 2,
PpKind::TplArg { .. } => 3,
_ => 0,
};
if braces == 0 {
continue;
}
let mut cur = node.span.start + braces;
for (i, part) in node_parts(dom, id).iter().enumerate() {
if i > 0 {
let sep = Span::new(node.span.src, cur, cur + 1);
assert_eq!(store.slice(sep), "|", "part separator in {input:?}");
cur += 1;
}
assert_eq!(part.span.start, cur, "part start in {input:?}");
let mut inner = part.span.start;
for &child in &part.children {
assert_eq!(dom.node(child).span.start, inner, "child gap in {input:?}");
inner = dom.node(child).span.end;
stack.push(child);
}
assert_eq!(inner, part.span.end, "part cover in {input:?}");
cur = part.span.end;
}
assert_eq!(cur + braces, node.span.end, "node cover in {input:?}");
}
}
#[test]
fn plain_text_is_a_single_node() {
let (store, dom) = build_top("hello мир");
assert_eq!(shape(&store, &dom), [row("text", "hello мир")]);
assert_eq!(dom.len(), 1, "coalesced into one arena node");
}
#[test]
fn empty_input_yields_empty_root() {
let (_, dom) = build_top("");
assert_eq!(dom.root(), []);
assert_eq!(dom.len(), 0);
}
#[test]
fn comment_mid_text_splits_runs() {
let (store, dom) = build_top("a<!--x-->b");
assert_eq!(
shape(&store, &dom),
[
row("text", "a"),
row("comment", "<!--x-->"),
row("text", "b")
]
);
}
#[test]
fn unterminated_comment_runs_to_eof() {
let (store, dom) = build_top("a<!-- no end");
assert_eq!(
shape(&store, &dom),
[row("text", "a"), row("comment", "<!-- no end")]
);
}
#[test]
fn comment_splits_template_name_inside_title_part() {
let (store, dom) = build_top("{{te<!-- -->mplate}}");
assert_eq!(
shape(&store, &dom),
[row("template", "{{te<!-- -->mplate}}")]
);
let parts = node_parts(&dom, dom.root()[0]);
assert_eq!(parts.len(), 1);
assert_eq!(
part_shape(&store, &dom, &parts[0]),
[
row("text", "te"),
row("comment", "<!-- -->"),
row("text", "mplate")
]
);
}
#[test]
fn basic_template() {
let (store, dom) = build_top("{{a}}");
assert_eq!(shape(&store, &dom), [row("template", "{{a}}")]);
let parts = node_parts(&dom, dom.root()[0]);
assert_eq!(parts.len(), 1);
assert_eq!(parts[0].eq, None);
assert_eq!(part_shape(&store, &dom, &parts[0]), [row("text", "a")]);
assert_eq!(store.slice(parts[0].span), "a");
}
#[test]
fn template_parts_record_first_equals() {
let (store, dom) = build_top("{{a|b|c=d}}");
let parts = node_parts(&dom, dom.root()[0]);
assert_eq!(parts.len(), 3);
assert_eq!(parts[1].eq, None);
assert_eq!(part_shape(&store, &dom, &parts[1]), [row("text", "b")]);
assert_eq!(
parts[2].eq,
Some(1),
"= is its own child at the recorded index"
);
assert_eq!(
part_shape(&store, &dom, &parts[2]),
[row("text", "c"), row("text", "="), row("text", "d")]
);
assert_eq!(store.slice(parts[2].span), "c=d");
}
#[test]
fn equals_in_title_part_stays_text() {
let (store, dom) = build_top("{{a=b}}");
let parts = node_parts(&dom, dom.root()[0]);
assert_eq!(parts[0].eq, None);
assert_eq!(part_shape(&store, &dom, &parts[0]), [row("text", "a=b")]);
}
#[test]
fn only_first_equals_splits_a_part() {
let (store, dom) = build_top("{{a|b=c=d}}");
let parts = node_parts(&dom, dom.root()[0]);
assert_eq!(parts[1].eq, Some(1));
assert_eq!(
part_shape(&store, &dom, &parts[1]),
[row("text", "b"), row("text", "="), row("text", "c=d")]
);
}
#[test]
fn nested_template_inside_part() {
let (store, dom) = build_top("{{a|{{b}}}}");
assert_eq!(shape(&store, &dom), [row("template", "{{a|{{b}}}}")]);
let parts = node_parts(&dom, dom.root()[0]);
assert_eq!(parts.len(), 2);
assert_eq!(
part_shape(&store, &dom, &parts[1]),
[row("template", "{{b}}")]
);
let inner = node_parts(&dom, parts[1].children[0]);
assert_eq!(part_shape(&store, &dom, &inner[0]), [row("text", "b")]);
}
#[test]
fn pipe_and_equals_inside_a_link_do_not_split_template_args() {
let (store, dom) = build_top("{{cite|title=[[A|B]]|url=[[C|D=E]]}}");
let parts = node_parts(&dom, dom.root()[0]);
let texts: Vec<String> = parts
.iter()
.map(|p| store.slice(p.span).to_owned())
.collect();
assert_eq!(texts, ["cite", "title=[[A|B]]", "url=[[C|D=E]]"]);
assert!(parts[1].eq.is_some() && parts[2].eq.is_some());
assert_tiles(&store, &dom, "{{cite|title=[[A|B]]|url=[[C|D=E]]}}");
}
#[test]
fn a_template_inside_a_link_arg_still_splits_its_own_pipes() {
let (store, dom) = build_top("{{a|[[x {{b|c|d}} y]]}}");
let outer = node_parts(&dom, dom.root()[0]);
let outer_texts: Vec<String> = outer
.iter()
.map(|p| store.slice(p.span).to_owned())
.collect();
assert_eq!(outer_texts, ["a", "[[x {{b|c|d}} y]]"]);
let inner_id = outer[1]
.children
.iter()
.copied()
.find(|&id| matches!(dom.node(id).kind, PpKind::Template { .. }))
.expect("inner template");
let inner = node_parts(&dom, inner_id);
let inner_texts: Vec<String> = inner
.iter()
.map(|p| store.slice(p.span).to_owned())
.collect();
assert_eq!(inner_texts, ["b", "c", "d"]);
}
#[test]
fn stray_close_bracket_cannot_underflow_bracket_depth() {
let (store, dom) = build_top("{{a|x]|y}}");
let parts = node_parts(&dom, dom.root()[0]);
let texts: Vec<String> = parts
.iter()
.map(|p| store.slice(p.span).to_owned())
.collect();
assert_eq!(texts, ["a", "x]", "y"]);
}
#[test]
fn tplarg_basic() {
let (store, dom) = build_top("{{{1}}}");
assert_eq!(shape(&store, &dom), [row("tplarg", "{{{1}}}")]);
let parts = node_parts(&dom, dom.root()[0]);
assert_eq!(parts.len(), 1);
assert_eq!(part_shape(&store, &dom, &parts[0]), [row("text", "1")]);
}
#[test]
fn tplarg_with_default() {
let (store, dom) = build_top("{{{1|def}}}");
let parts = node_parts(&dom, dom.root()[0]);
assert_eq!(parts.len(), 2);
assert_eq!(parts[1].eq, None);
assert_eq!(part_shape(&store, &dom, &parts[1]), [row("text", "def")]);
}
#[test]
fn tplarg_extra_pipes_fold_into_default() {
let (store, dom) = build_top("{{{1|a|b}}}");
let parts = node_parts(&dom, dom.root()[0]);
assert_eq!(parts.len(), 2, "at most one default part");
assert_eq!(part_shape(&store, &dom, &parts[1]), [row("text", "a|b")]);
assert_eq!(store.slice(parts[1].span), "a|b");
}
#[test]
fn tplarg_drops_equals_split() {
let (store, dom) = build_top("{{{n|a=b}}}");
let parts = node_parts(&dom, dom.root()[0]);
assert_eq!(parts[1].eq, None, "= has no meaning in tplarg parts");
assert_eq!(
part_shape(&store, &dom, &parts[1]),
[row("text", "a=b")],
"the = split merges back into one text node"
);
}
#[test]
fn four_braces_are_brace_tplarg_brace() {
let (store, dom) = build_top("{{{{x}}}}");
assert_eq!(
shape(&store, &dom),
[row("text", "{"), row("tplarg", "{{{x}}}"), row("text", "}")]
);
}
#[test]
fn five_braces_are_template_of_tplarg() {
let (store, dom) = build_top("{{{{{1}}}}}");
assert_eq!(shape(&store, &dom), [row("template", "{{{{{1}}}}}")]);
let parts = node_parts(&dom, dom.root()[0]);
assert_eq!(
part_shape(&store, &dom, &parts[0]),
[row("tplarg", "{{{1}}}")]
);
}
#[test]
fn unclosed_template_reverts_to_one_text_node() {
let (store, dom) = build_top("x{{a|b=c");
assert_eq!(shape(&store, &dom), [row("text", "x{{a|b=c")]);
assert_eq!(dom.len(), 1, "breakSyntax pieces coalesce back");
}
#[test]
fn unclosed_outer_keeps_closed_inner() {
let (store, dom) = build_top("{{a|{{b}}");
assert_eq!(
shape(&store, &dom),
[row("text", "{{a|"), row("template", "{{b}}")]
);
}
#[test]
fn single_close_brace_stays_literal() {
let (store, dom) = build_top("{{x}");
assert_eq!(shape(&store, &dom), [row("text", "{{x}")]);
assert_eq!(dom.len(), 1);
}
#[test]
fn stray_close_braces_are_text() {
let (store, dom) = build_top("a}}b");
assert_eq!(shape(&store, &dom), [row("text", "a}}b")]);
assert_eq!(
node_parts(&dom, dom.root()[0]),
[],
"no parts on a text node"
);
}
#[test]
fn surplus_close_braces_stay_literal() {
let (store, dom) = build_top("{{a}}}");
assert_eq!(
shape(&store, &dom),
[row("template", "{{a}}"), row("text", "}")]
);
}
#[test]
fn triple_open_double_close_leaves_leading_brace() {
let (store, dom) = build_top("{{{a}}");
assert_eq!(
shape(&store, &dom),
[row("text", "{"), row("template", "{{a}}")]
);
}
#[test]
fn table_markup_is_not_braces() {
let (store, dom) = build_top("{|x|}\n|-\n|}");
assert_eq!(shape(&store, &dom), [row("text", "{|x|}\n|-\n|}")]);
}
#[test]
fn empty_template_and_empty_parts() {
let (_, dom) = build_top("{{}}");
let parts = node_parts(&dom, dom.root()[0]);
assert_eq!(parts.len(), 1);
assert_eq!(parts[0].children, []);
assert!(parts[0].span.is_empty());
let (_, dom) = build_top("{{a|}}");
let parts = node_parts(&dom, dom.root()[0]);
assert_eq!(parts.len(), 2);
assert_eq!(parts[1].children, []);
assert_eq!(parts[1].eq, None);
}
fn ext_fields(
store: &SourceStore,
dom: &PpDom,
id: PpNodeId,
) -> (String, Option<String>, Option<String>, bool) {
let (mut out_name, mut attrs, mut body_text, mut is_closed) =
(String::new(), None, None, false);
if let PpKind::ExtTag {
name,
attributes,
body,
closed,
} = &dom.node(id).kind
{
out_name = store.slice(*name).to_owned();
attrs = attributes.map(|s| store.slice(s).to_owned());
body_text = body.map(|s| store.slice(s).to_owned());
is_closed = *closed;
}
(out_name, attrs, body_text, is_closed)
}
#[test]
fn surplus_close_braces_after_full_close_stay_literal() {
let (store, dom) = build_top("{{a}}}}");
assert_eq!(dom.root().len(), 2);
let first = &dom.node(dom.root()[0]).kind;
assert_eq!(
std::mem::discriminant(first),
std::mem::discriminant(&PpKind::Template { parts: Vec::new() })
);
assert_eq!(store.slice(dom.node(dom.root()[1]).span), "}}");
}
#[test]
fn ext_fields_returns_defaults_for_non_ext_nodes() {
let (store, dom) = build_top("plain text");
let (name, attrs, body, closed) = ext_fields(&store, &dom, dom.root()[0]);
assert_eq!(name, "");
assert_eq!(attrs, None);
assert_eq!(body, None);
assert!(!closed);
}
#[test]
fn close_tag_lookalikes_are_skipped() {
let (store, dom) = build_top("<nowiki>a<i></b></nowiki x>b</nowiki>c");
let (name, _, body, closed) = ext_fields(&store, &dom, dom.root()[0]);
assert_eq!(name, "nowiki");
assert_eq!(body.as_deref(), Some("a<i></b></nowiki x>b"));
assert!(closed);
}
#[test]
fn ext_tag_body_is_opaque_to_braces() {
let (store, dom) = build_top("<nowiki>{{not a template}}</nowiki>");
assert_eq!(
shape(&store, &dom),
[row("ext", "<nowiki>{{not a template}}</nowiki>")]
);
assert_eq!(
ext_fields(&store, &dom, dom.root()[0]),
(
"nowiki".to_owned(),
None,
Some("{{not a template}}".to_owned()),
true
)
);
}
#[test]
fn self_closing_ext_tag_has_no_body() {
let (store, dom) = build_top("a<nowiki/>b");
assert_eq!(
shape(&store, &dom),
[row("text", "a"), row("ext", "<nowiki/>"), row("text", "b")]
);
assert_eq!(
ext_fields(&store, &dom, dom.root()[1]),
("nowiki".to_owned(), None, None, true)
);
}
#[test]
fn ext_tag_captures_raw_attributes() {
let (store, dom) = build_top("<ref name=\"x\">body</ref>");
assert_eq!(
ext_fields(&store, &dom, dom.root()[0]),
(
"ref".to_owned(),
Some(" name=\"x\"".to_owned()),
Some("body".to_owned()),
true
)
);
}
#[test]
fn self_closing_ext_tag_with_attributes() {
let (store, dom) = build_top("<ref name=\"x\"/>");
assert_eq!(
ext_fields(&store, &dom, dom.root()[0]),
("ref".to_owned(), Some(" name=\"x\"".to_owned()), None, true)
);
}
#[test]
fn unclosed_ext_tag_captures_to_eof() {
let (store, dom) = build_top("a<ref>no closing");
assert_eq!(
shape(&store, &dom),
[row("text", "a"), row("ext", "<ref>no closing")]
);
assert_eq!(
ext_fields(&store, &dom, dom.root()[1]),
("ref".to_owned(), None, Some("no closing".to_owned()), false)
);
}
#[test]
fn ext_tag_names_match_case_insensitively() {
let (store, dom) = build_top("<NoWiki>x</NOWIKI>");
assert_eq!(
ext_fields(&store, &dom, dom.root()[0]),
("NoWiki".to_owned(), None, Some("x".to_owned()), true)
);
}
#[test]
fn longer_names_are_not_ext_tags() {
let (store, dom) = build_top("<nowikix>{{a}}</nowikix>");
assert_eq!(
shape(&store, &dom),
[
row("text", "<nowikix>"),
row("template", "{{a}}"),
row("text", "</nowikix>")
]
);
}
#[test]
fn ext_tag_inside_template_part_hides_its_pipe() {
let (store, dom) = build_top("{{a|<nowiki>|</nowiki>}}");
let parts = node_parts(&dom, dom.root()[0]);
assert_eq!(parts.len(), 2, "the pipe inside the body does not split");
assert_eq!(
part_shape(&store, &dom, &parts[1]),
[row("ext", "<nowiki>|</nowiki>")]
);
}
#[test]
fn ext_tag_without_gt_is_literal() {
let (store, dom) = build_top("a<nowiki");
assert_eq!(shape(&store, &dom), [row("text", "a<nowiki")]);
let (store, dom) = build_top("<ref name=");
assert_eq!(shape(&store, &dom), [row("text", "<ref name=")]);
}
#[test]
fn close_tag_allows_whitespace_before_gt() {
let (store, dom) = build_top("<nowiki>a</nowiki >b");
assert_eq!(
shape(&store, &dom),
[row("ext", "<nowiki>a</nowiki >"), row("text", "b")]
);
}
#[test]
fn wrong_close_tag_does_not_end_the_capture() {
let (store, dom) = build_top("<nowiki>a</nowikix></nowiki>");
assert_eq!(
ext_fields(&store, &dom, dom.root()[0]),
(
"nowiki".to_owned(),
None,
Some("a</nowikix>".to_owned()),
true
)
);
}
#[test]
fn stray_ext_close_tag_is_literal() {
let (store, dom) = build_top("a</nowiki>b");
assert_eq!(shape(&store, &dom), [row("text", "a</nowiki>b")]);
}
#[test]
fn html_tags_are_literal() {
let (store, dom) = build_top("<b>x</b> <span class=\"y\">z</span>");
assert_eq!(
shape(&store, &dom),
[row("text", "<b>x</b> <span class=\"y\">z</span>")]
);
}
#[test]
fn toplevel_includeonly_region_is_one_ignored_node() {
let (store, dom) = build_top("a<includeonly>{{x}}</includeonly>b");
assert_eq!(
shape(&store, &dom),
[
row("text", "a"),
row("ignored", "<includeonly>{{x}}</includeonly>"),
row("text", "b")
]
);
}
#[test]
fn toplevel_unclosed_includeonly_ignores_to_eof() {
let (store, dom) = build_top("a<includeonly>rest {{x}}");
assert_eq!(
shape(&store, &dom),
[row("text", "a"), row("ignored", "<includeonly>rest {{x}}")]
);
}
#[test]
fn toplevel_noinclude_tags_ignored_content_kept() {
let (store, dom) = build_top("a<noinclude>{{x}}</noinclude>b");
assert_eq!(
shape(&store, &dom),
[
row("text", "a"),
row("ignored", "<noinclude>"),
row("template", "{{x}}"),
row("ignored", "</noinclude>"),
row("text", "b")
]
);
}
#[test]
fn toplevel_onlyinclude_tags_ignored_content_kept() {
let (store, dom) = build_top("<onlyinclude>x</onlyinclude>");
assert_eq!(
shape(&store, &dom),
[
row("ignored", "<onlyinclude>"),
row("text", "x"),
row("ignored", "</onlyinclude>")
]
);
}
#[test]
fn toplevel_stray_includeonly_close_is_literal() {
let (store, dom) = build_top("a</includeonly>b");
assert_eq!(shape(&store, &dom), [row("text", "a</includeonly>b")]);
}
#[test]
fn directive_tags_accept_attributes_and_self_closing_forms() {
let (store, dom) = build_top("<noinclude foo=\"1\">x</noinclude>");
assert_eq!(
shape(&store, &dom),
[
row("ignored", "<noinclude foo=\"1\">"),
row("text", "x"),
row("ignored", "</noinclude>")
]
);
let (store, dom) = build_top("a<includeonly/>b");
assert_eq!(
shape(&store, &dom),
[
row("text", "a"),
row("ignored", "<includeonly/>"),
row("text", "b")
]
);
}
#[test]
fn unclosed_directive_tag_markup_ignores_to_eof() {
let (store, dom) = build_top("a<noinclude foo");
assert_eq!(
shape(&store, &dom),
[row("text", "a"), row("ignored", "<noinclude foo")]
);
}
fn build_tcl(input: &str) -> (SourceStore, PpDom) {
build_mode(input, InclusionMode::Transcluded)
}
#[test]
fn transcluded_noinclude_region_is_ignored() {
let (store, dom) = build_tcl("a<noinclude>{{x}}</noinclude>b");
assert_eq!(
shape(&store, &dom),
[
row("text", "a"),
row("ignored", "<noinclude>{{x}}</noinclude>"),
row("text", "b")
]
);
}
#[test]
fn transcluded_unclosed_noinclude_ignores_to_eof() {
let (store, dom) = build_tcl("a<noinclude>rest");
assert_eq!(
shape(&store, &dom),
[row("text", "a"), row("ignored", "<noinclude>rest")]
);
}
#[test]
fn transcluded_includeonly_tags_ignored_content_kept() {
let (store, dom) = build_tcl("a<includeonly>{{x}}</includeonly>b");
assert_eq!(
shape(&store, &dom),
[
row("text", "a"),
row("ignored", "<includeonly>"),
row("template", "{{x}}"),
row("ignored", "</includeonly>"),
row("text", "b")
]
);
}
#[test]
fn transcluded_stray_noinclude_close_is_literal() {
let (store, dom) = build_tcl("a</noinclude>b");
assert_eq!(shape(&store, &dom), [row("text", "a</noinclude>b")]);
}
#[test]
fn transcluded_onlyinclude_keeps_only_its_sections() {
let (store, dom) =
build_tcl("aa<onlyinclude>x</onlyinclude>bb<onlyinclude>y</onlyinclude>cc");
assert_eq!(
shape(&store, &dom),
[
row("ignored", "aa"),
row("ignored", "<onlyinclude>"),
row("text", "x"),
row("ignored", "</onlyinclude>"),
row("ignored", "bb"),
row("ignored", "<onlyinclude>"),
row("text", "y"),
row("ignored", "</onlyinclude>"),
row("ignored", "cc")
]
);
}
#[test]
fn transcluded_onlyinclude_at_start_and_unclosed() {
let (store, dom) = build_tcl("<onlyinclude>{{x}} to eof");
assert_eq!(
shape(&store, &dom),
[
row("ignored", "<onlyinclude>"),
row("template", "{{x}}"),
row("text", " to eof")
]
);
}
#[test]
fn transcluded_onlyinclude_close_at_eof_has_no_trailing_node() {
let (store, dom) = build_tcl("a<onlyinclude>x</onlyinclude>");
assert_eq!(
shape(&store, &dom),
[
row("ignored", "a"),
row("ignored", "<onlyinclude>"),
row("text", "x"),
row("ignored", "</onlyinclude>")
]
);
}
#[test]
fn transcluded_onlyinclude_inside_open_template_keeps_braces_alive() {
let (store, dom) =
build_tcl("<onlyinclude>{{a</onlyinclude>junk<onlyinclude>|b}}</onlyinclude>");
assert_eq!(
shape(&store, &dom),
[
row("ignored", "<onlyinclude>"),
row("template", "{{a</onlyinclude>junk<onlyinclude>|b}}"),
row("ignored", "</onlyinclude>")
]
);
let parts = node_parts(&dom, dom.root()[1]);
assert_eq!(parts.len(), 2);
assert_eq!(
part_shape(&store, &dom, &parts[0]),
[
row("text", "a"),
row("ignored", "</onlyinclude>"),
row("ignored", "junk"),
row("ignored", "<onlyinclude>")
]
);
}
#[test]
fn transcluded_without_onlyinclude_stray_close_is_literal() {
let (store, dom) = build_tcl("a</onlyinclude>b");
assert_eq!(shape(&store, &dom), [row("text", "a</onlyinclude>b")]);
}
#[test]
fn transcluded_nested_onlyinclude_open_is_literal() {
let (store, dom) = build_tcl("<onlyinclude>a<onlyinclude>b</onlyinclude>c");
assert_eq!(
shape(&store, &dom),
[
row("ignored", "<onlyinclude>"),
row("text", "a<onlyinclude>b"),
row("ignored", "</onlyinclude>"),
row("ignored", "c")
]
);
}
#[test]
fn toplevel_ignores_onlyinclude_selection() {
let (store, dom) = build_top("aa<onlyinclude>x</onlyinclude>bb");
assert_eq!(
shape(&store, &dom),
[
row("text", "aa"),
row("ignored", "<onlyinclude>"),
row("text", "x"),
row("ignored", "</onlyinclude>"),
row("text", "bb")
]
);
}
#[test]
fn directive_region_close_matches_case_insensitively() {
let (store, dom) = build_top("<includeonly>x</INCLUDEONLY>y");
assert_eq!(
shape(&store, &dom),
[
row("ignored", "<includeonly>x</INCLUDEONLY>"),
row("text", "y")
]
);
}
#[test]
fn directive_inside_template_part_stays_a_part_child() {
let (store, dom) = build_top("{{a|<noinclude>b</noinclude>}}");
let parts = node_parts(&dom, dom.root()[0]);
assert_eq!(
part_shape(&store, &dom, &parts[1]),
[
row("ignored", "<noinclude>"),
row("text", "b"),
row("ignored", "</noinclude>")
]
);
}
#[test]
fn span_tiling_holds_across_the_construct_matrix() {
let inputs = [
"",
"plain",
"{{a}}",
"{{a|b|c=d}}",
"{{a|{{b}}|{{{c|}}}}}",
"{{{1}}}",
"{{{1|def}}}",
"{{{{x}}}}",
"{{{{{1}}}}}",
"{{{{{{x}}}}}}",
"{{a",
"{{a|b",
"{{x}",
"a}}b",
"}}{{",
"{{a}}}}",
"{{te<!-- -->mplate}}",
"<!-- lone",
"<nowiki>{{x}}</nowiki>",
"<ref name=\"x\">{{y}}</ref>",
"<ref>unclosed",
"a<includeonly>x</includeonly>b",
"a<noinclude>x</noinclude>b",
"q<onlyinclude>w</onlyinclude>e",
"{{a|=}}",
"{{=}}",
"{{a||b}}",
"пример {{шаблон|значение}} текст",
];
for input in inputs {
build_mode(input, InclusionMode::TopLevel);
build_mode(input, InclusionMode::Transcluded);
}
}
}