use std::borrow::Cow;
use crate::ast::Node;
use crate::diag::Diagnostic;
use crate::tokenizer::{self, Inline};
#[derive(Debug)]
pub struct Parsed<'a> {
pub nodes: Vec<Node<'a>>,
pub diagnostics: Vec<Diagnostic>,
}
pub fn parse(wikitext: &str) -> Parsed<'_> {
let mut nodes = Vec::new();
let mut diagnostics = Vec::new();
for (start, block) in blocks(wikitext) {
let span = start..start + block.len();
let node = if let Some(heading) = parse_heading(block) {
heading
} else if let Some(list) = parse_list(block) {
list
} else if let Some(pre) = parse_pre(block) {
pre
} else if let Some(table) = parse_table(block) {
table
} else if let Some((code, msg)) = unsupported_reason(&strip_inline_templates(block)) {
diagnostics.push(Diagnostic::unsupported(code, span.clone(), msg));
Node::Unsupported(Cow::Borrowed(block))
} else {
Node::Paragraph(parse_inline(&tokenizer::inline(block)))
};
if !matches!(node, Node::Unsupported(_)) && block.contains("{{") {
diagnostics.push(Diagnostic::warning(
"W-TEMPLATE",
span,
"template content dropped (not expanded)",
));
}
nodes.push(node);
}
Parsed { nodes, diagnostics }
}
fn blocks(s: &str) -> Vec<(usize, &str)> {
let mut out = Vec::new();
let mut start: Option<usize> = None;
let mut off = 0;
let mut brace_depth = 0usize;
let mut table_depth = 0usize;
for line in s.split_inclusive('\n') {
let here = off;
off += line.len();
let content = line.trim_end_matches('\n');
if table_depth > 0 {
(table_depth, brace_depth) = update_table_brace(table_depth, brace_depth, content);
if table_depth == 0 {
if let Some(st) = start.take() {
let block = s[st..off].trim_end_matches('\n');
if !block.is_empty() {
out.push((st, block));
}
}
}
continue;
}
let at_top = brace_depth == 0;
let opens_table = at_top
&& matches!(content.as_bytes().first(), Some(b'{' | b' ' | b'\t'))
&& content.trim_start().starts_with("{|");
let is_heading = at_top && heading_parts(content).is_some();
if at_top && (content.trim().is_empty() || is_heading || opens_table) {
if let Some(st) = start.take() {
let block = s[st..here].trim_end_matches('\n');
if !block.is_empty() {
out.push((st, block));
}
}
if is_heading {
out.push((here, content));
}
if opens_table {
start = Some(here);
(table_depth, brace_depth) = update_table_brace(0, brace_depth, content);
continue;
}
} else if start.is_none() {
start = Some(here);
}
brace_depth = update_brace_depth(brace_depth, content);
}
if let Some(st) = start {
let block = s[st..off].trim_end_matches('\n');
if !block.is_empty() {
out.push((st, block));
}
}
out
}
fn update_brace_depth(mut depth: usize, line: &str) -> usize {
let b = line.as_bytes();
let mut i = 0;
while i + 1 < b.len() {
if b[i] == b'{' && b[i + 1] == b'{' {
depth += 1;
i += 2;
} else if b[i] == b'}' && b[i + 1] == b'}' {
depth = depth.saturating_sub(1);
i += 2;
} else {
i += 1;
}
}
depth
}
fn update_table_brace(mut table: usize, mut brace: usize, line: &str) -> (usize, usize) {
let b = line.as_bytes();
let mut i = 0;
while i + 1 < b.len() {
match (b[i], b[i + 1]) {
(b'{', b'{') => brace += 1,
(b'}', b'}') => brace = brace.saturating_sub(1),
(b'{', b'|') if brace == 0 => table += 1,
(b'|', b'}') if brace == 0 => table = table.saturating_sub(1),
_ => {
i += 1;
continue;
}
}
i += 2;
}
(table, brace)
}
fn heading_parts(line: &str) -> Option<(u8, &str)> {
if line.contains('\n') {
return None;
}
let t = line.trim();
let lead = t.bytes().take_while(|&b| b == b'=').count();
let trail = t.bytes().rev().take_while(|&b| b == b'=').count();
let level = lead.min(trail);
if level == 0 || t.len() <= level * 2 {
return None;
}
Some((level.min(6) as u8, t[level..t.len() - level].trim()))
}
fn parse_heading(block: &str) -> Option<Node<'_>> {
let (level, inner) = heading_parts(block)?;
Some(Node::Heading {
level,
content: parse_inline(&tokenizer::inline(inner)),
})
}
fn parse_list(block: &str) -> Option<Node<'_>> {
let mut lines: Vec<(&str, &str)> = Vec::new();
for line in block.lines() {
let prefix_len = line
.bytes()
.take_while(|b| matches!(b, b'*' | b'#' | b':' | b';'))
.count();
if prefix_len == 0 {
return None;
}
lines.push((&line[..prefix_len], line[prefix_len..].trim_start()));
}
build_list(&lines, 0)
}
fn build_list<'a>(lines: &[(&'a str, &'a str)], depth: usize) -> Option<Node<'a>> {
if lines.first()?.0.len() != depth + 1 {
return None;
}
let ordered = lines[0].0.as_bytes()[depth] == b'#';
let mut items: Vec<Vec<Node<'a>>> = Vec::new();
let mut i = 0;
while i < lines.len() {
let (prefix, content) = lines[i];
if prefix.len() != depth + 1 {
return None;
}
let mut item = parse_inline(&tokenizer::inline(content));
i += 1;
let nested_start = i;
while i < lines.len() && lines[i].0.len() > depth + 1 {
i += 1;
}
if i > nested_start {
item.push(build_list(&lines[nested_start..i], depth + 1)?);
}
items.push(item);
}
Some(Node::List { ordered, items })
}
fn parse_pre(block: &str) -> Option<Node<'_>> {
if !block.lines().all(|l| l.starts_with(' ')) {
return None;
}
if block.contains("{|") || has_tag(block) {
return None;
}
let lines = block
.lines()
.map(|l| parse_inline(&tokenizer::inline(&l[1..])))
.collect();
Some(Node::Preformatted(lines))
}
fn table_logical_lines(block: &str) -> Vec<&str> {
let b = block.as_bytes();
let mut out = Vec::new();
let mut start = 0;
let mut i = 0;
let mut in_ref = false;
while i < b.len() {
if in_ref {
if b[i] == b'<' && b[i..].len() >= 6 && b[i..i + 6].eq_ignore_ascii_case(b"</ref>") {
in_ref = false;
i += 6;
continue;
}
i += 1;
} else if b[i] == b'\n' {
out.push(&block[start..i]);
start = i + 1;
i += 1;
} else if b[i] == b'<' && ref_opens_body(&block[i..]) {
in_ref = true;
i += 1;
} else {
i += 1;
}
}
if start < b.len() {
out.push(&block[start..]);
}
out
}
fn ref_opens_body(s: &str) -> bool {
let b = s.as_bytes();
if b.len() < 4 || !b[1..4].eq_ignore_ascii_case(b"ref") {
return false;
}
if !matches!(b.get(4), Some(b' ' | b'\t' | b'\n' | b'\r' | b'>' | b'/')) {
return false;
}
match tokenizer::tag_open_end(s, 4) {
Some((_, self_closing)) => !self_closing,
None => true,
}
}
fn parse_table(block: &str) -> Option<Node<'_>> {
if !block.trim_start().starts_with("{|") {
return None;
}
let mut rows: Vec<Vec<Vec<Node>>> = Vec::new();
let mut current: Vec<Vec<Node>> = Vec::new();
let mut started = false;
for line in table_logical_lines(block) {
let l = line.trim_start();
if l.is_empty() {
continue; }
if l.starts_with("{|") || l.starts_with("|}") || l.starts_with("|+") {
continue; } else if l.starts_with("|-") {
if started {
rows.push(std::mem::take(&mut current));
}
started = true;
} else if let Some(rest) = l.strip_prefix('!') {
for part in rest.split("!!") {
for cell in part.split("||") {
let (attrs, content) = cell_split(cell);
if attrs.is_some_and(spanning_attrs) {
return None;
}
current.push(parse_inline(&tokenizer::inline(content)));
}
}
started = true;
} else if let Some(rest) = l.strip_prefix('|') {
for cell in rest.split("||") {
let (attrs, content) = cell_split(cell);
if attrs.is_some_and(spanning_attrs) {
return None;
}
current.push(parse_inline(&tokenizer::inline(content)));
}
started = true;
} else {
return None; }
}
if !current.is_empty() {
rows.push(current);
}
Some(Node::Table { rows })
}
fn spanning_attrs(attrs: &str) -> bool {
tokenizer::find_ci(attrs, "colspan").is_some() || tokenizer::find_ci(attrs, "rowspan").is_some()
}
fn cell_split(cell: &str) -> (Option<&str>, &str) {
let b = cell.as_bytes();
let (mut link, mut tmpl) = (0u32, 0u32);
let mut quote = 0u8; let mut last = 0u8; let mut i = 0;
while i < b.len() {
if quote != 0 {
if b[i] == quote {
quote = 0;
last = b[i];
}
i += 1;
continue;
}
let two = i + 1 < b.len();
if two && b[i] == b'[' && b[i + 1] == b'[' {
link += 1;
i += 2;
} else if two && b[i] == b']' && b[i + 1] == b']' {
link = link.saturating_sub(1);
i += 2;
} else if two && b[i] == b'{' && b[i + 1] == b'{' {
tmpl += 1;
i += 2;
} else if two && b[i] == b'}' && b[i + 1] == b'}' {
tmpl = tmpl.saturating_sub(1);
i += 2;
} else if (b[i] == b'"' || b[i] == b'\'') && last == b'=' && link == 0 && tmpl == 0 {
quote = b[i];
i += 1;
} else if b[i] == b'|' && link == 0 && tmpl == 0 {
return (Some(&cell[..i]), cell[i + 1..].trim());
} else {
if !b[i].is_ascii_whitespace() {
last = b[i];
}
i += 1;
}
}
(None, cell.trim())
}
fn parse_inline<'a>(tokens: &[Inline<'a>]) -> Vec<Node<'a>> {
let mut out = Vec::new();
let mut i = 0;
let (mut no_link, mut no_ext, mut no_bold, mut no_italic) = (false, false, false, false);
let media_close = if tokens.iter().any(|t| matches!(t, Inline::LinkOpen)) {
link_close_matches(tokens)
} else {
Vec::new()
};
while i < tokens.len() {
match tokens[i] {
Inline::Text(s) => {
out.push(Node::Text(Cow::Borrowed(s)));
i += 1;
}
Inline::LinkOpen => {
let found = if no_link {
None
} else {
find(tokens, i + 1, Inline::LinkClose)
};
match found {
Some(close) => {
let close = if link_target_is_nonprose(tokens, i + 1) {
media_close[i].unwrap_or(close)
} else {
close
};
out.push(make_link(&tokens[i + 1..close]));
i = close + 1;
}
None => {
no_link = true;
out.push(Node::Text(Cow::Borrowed("[[")));
i += 1;
}
}
}
Inline::ExtOpen => {
let found = if no_ext {
None
} else {
find(tokens, i + 1, Inline::ExtClose)
};
match found {
Some(close) => {
out.push(make_ext_link(&tokens[i + 1..close]));
i = close + 1;
}
None => {
no_ext = true;
out.push(Node::Text(Cow::Borrowed("[")));
i += 1;
}
}
}
Inline::Bold => {
let found = if no_bold {
None
} else {
find(tokens, i + 1, Inline::Bold)
};
match found {
Some(close) => {
out.push(Node::Bold(parse_inline(&tokens[i + 1..close])));
i = close + 1;
}
None => {
no_bold = true;
out.push(Node::Text(Cow::Borrowed("'''")));
i += 1;
}
}
}
Inline::Italic => {
let found = if no_italic {
None
} else {
find(tokens, i + 1, Inline::Italic)
};
match found {
Some(close) => {
out.push(Node::Italic(parse_inline(&tokens[i + 1..close])));
i = close + 1;
}
None => {
no_italic = true;
out.push(Node::Text(Cow::Borrowed("''")));
i += 1;
}
}
}
Inline::LinkClose => {
out.push(Node::Text(Cow::Borrowed("]]")));
i += 1;
}
Inline::ExtClose => {
out.push(Node::Text(Cow::Borrowed("]")));
i += 1;
}
Inline::Pipe => {
out.push(Node::Text(Cow::Borrowed("|")));
i += 1;
}
}
}
out
}
fn find(tokens: &[Inline], from: usize, target: Inline) -> Option<usize> {
tokens[from..]
.iter()
.position(|t| std::mem::discriminant(t) == std::mem::discriminant(&target))
.map(|p| from + p)
}
fn link_close_matches(tokens: &[Inline]) -> Vec<Option<usize>> {
let mut matched = vec![None; tokens.len()];
let mut open_stack: Vec<usize> = Vec::new();
for (idx, t) in tokens.iter().enumerate() {
match t {
Inline::LinkOpen => open_stack.push(idx),
Inline::LinkClose => {
if let Some(open) = open_stack.pop() {
matched[open] = Some(idx);
}
}
_ => {}
}
}
matched
}
fn link_target_is_nonprose(tokens: &[Inline], start: usize) -> bool {
matches!(tokens.get(start), Some(Inline::Text(s)) if is_nonprose_target(s))
}
fn make_link<'a>(inner: &[Inline<'a>]) -> Node<'a> {
match inner.iter().position(|t| matches!(t, Inline::Pipe)) {
Some(p) => {
let target = concat_text(&inner[..p]);
if is_nonprose_target(&target) {
return Node::Text(Cow::Borrowed(""));
}
let label = parse_inline(&inner[p + 1..]);
let label = if label.is_empty() {
vec![Node::Text(target.clone())]
} else {
label
};
Node::Link { target, label }
}
None => {
let target = concat_text(inner);
if is_nonprose_target(&target) {
return Node::Text(Cow::Borrowed(""));
}
Node::Link {
label: vec![Node::Text(target.clone())],
target,
}
}
}
}
fn is_nonprose_target(target: &str) -> bool {
let ns = target.split(':').next().unwrap_or("").trim();
ns.eq_ignore_ascii_case("file")
|| ns.eq_ignore_ascii_case("image")
|| ns.eq_ignore_ascii_case("category")
}
fn make_ext_link<'a>(inner: &[Inline<'a>]) -> Node<'a> {
let raw = concat_text(inner);
if let Some((url, label)) = raw.split_once(char::is_whitespace) {
Node::Link {
target: Cow::Owned(url.to_string()),
label: vec![Node::Text(Cow::Owned(label.trim_start().to_string()))],
}
} else {
Node::Link {
target: Cow::Owned(raw.into_owned()),
label: Vec::new(),
}
}
}
fn concat_text<'a>(tokens: &[Inline<'a>]) -> Cow<'a, str> {
match tokens {
[Inline::Text(s)] => Cow::Borrowed(s),
_ => {
let mut s = String::new();
for t in tokens {
if let Inline::Text(x) = t {
s.push_str(x);
}
}
Cow::Owned(s)
}
}
}
fn strip_inline_templates(s: &str) -> Cow<'_, str> {
if !s.contains("{{") {
return Cow::Borrowed(s);
}
let b = s.as_bytes();
let mut out = String::with_capacity(s.len());
let mut i = 0;
let mut seg = 0;
while i + 1 < b.len() {
if b[i] == b'{' && b[i + 1] == b'{' {
out.push_str(&s[seg..i]);
let mut depth = 0usize;
let mut closed = false;
while i + 1 < b.len() {
if b[i] == b'{' && b[i + 1] == b'{' {
depth += 1;
i += 2;
} else if b[i] == b'}' && b[i + 1] == b'}' {
depth -= 1;
i += 2;
if depth == 0 {
closed = true;
break;
}
} else {
i += 1;
}
}
if !closed {
i = b.len();
}
seg = i;
} else {
i += 1;
}
}
out.push_str(&s[seg..]);
Cow::Owned(out)
}
fn unsupported_reason(block: &str) -> Option<(&'static str, String)> {
if block.contains("{|") {
return Some(("U-TABLE", "tables are not parsed yet".into()));
}
if has_tag(block) {
return Some(("U-HTML", "HTML/ref tags are not parsed yet".into()));
}
for line in block.lines() {
let l = line.trim_start();
if l.starts_with(['*', '#', ':', ';']) {
return Some(("U-LIST", "irregular list nesting not parsed".into()));
}
if l.starts_with('|') || l.starts_with('!') {
return Some(("U-TABLE", "table markup is not parsed yet".into()));
}
if line.starts_with([' ', '\t']) {
return Some(("U-PRE", "preformatted blocks are not parsed yet".into()));
}
}
None
}
fn has_tag(s: &str) -> bool {
let b = s.as_bytes();
let mut i = 0;
while i < b.len() {
if b[i] != b'<' {
i += 1;
continue;
}
if let Some(span) = tokenizer::tag_span(s, i) {
i = span.end();
continue;
}
let mut j = i + 1;
if b.get(j) == Some(&b'/') {
j += 1;
}
let name_start = j;
while j < b.len() && b[j].is_ascii_alphabetic() {
j += 1;
}
if j > name_start
&& matches!(
tokenizer::tag_kind(&s[name_start..j].to_ascii_lowercase()),
tokenizer::TagKind::Unsupported
)
{
return true;
}
i += 1;
}
false
}
#[cfg(test)]
mod tests {
use super::*;
use crate::render;
#[test]
fn parses_subset_to_ast_and_text() {
let wt = "== History ==\n\nEarth is the '''third''' [[Planet|planet]].";
let p = parse(wt);
assert!(p.diagnostics.is_empty(), "diags: {:?}", p.diagnostics);
assert_eq!(
render::plain(&p.nodes),
"History\n\nEarth is the third planet."
);
assert!(matches!(p.nodes[0], Node::Heading { level: 2, .. }));
}
#[test]
fn blocks_keeps_multiline_template_whole() {
let wt = "{{infobox\n|a=1\n\n|b=2\n}}";
let bs = blocks(wt);
assert_eq!(bs.len(), 1, "expected one block, got {bs:?}");
assert_eq!(bs[0].1, "{{infobox\n|a=1\n\n|b=2\n}}");
}
#[test]
fn blocks_still_splits_normal_paragraphs() {
let bs = blocks("Para one.\n\nPara two.");
assert_eq!(bs.len(), 2, "got {bs:?}");
assert_eq!(bs[0].1, "Para one.");
assert_eq!(bs[1].1, "Para two.");
}
#[test]
fn blocks_unglue_table_from_surrounding_prose() {
let wt = "Prior.\n{| class=\"wikitable\"\n|-\n| a\n|}\nAfter.";
let bs = blocks(wt);
let texts: Vec<&str> = bs.iter().map(|(_, b)| *b).collect();
assert_eq!(
texts,
vec!["Prior.", "{| class=\"wikitable\"\n|-\n| a\n|}", "After."],
"got {bs:?}"
);
}
#[test]
fn blocks_table_with_internal_blank_line_stays_one_block() {
let wt = "{|\n| a\n\n| b\n|}";
let bs = blocks(wt);
assert_eq!(bs.len(), 1, "got {bs:?}");
assert_eq!(bs[0].1, "{|\n| a\n\n| b\n|}");
}
#[test]
fn multiline_template_is_dropped_not_leaked() {
let wt = "Intro.\n\n{{#invoke:Sports table|main\n|name_A=Alpha\n\n|win_A=2 |loss_A=0\n}}\n\nOutro.";
let p = parse(wt);
let text = render::plain(&p.nodes);
assert!(!text.contains("{{"), "leaked template markup: {text:?}");
assert!(!text.contains("name_A"), "leaked template param: {text:?}");
assert!(text.contains("Intro."), "lost prose: {text:?}");
assert!(text.contains("Outro."), "lost prose: {text:?}");
let codes: Vec<&str> = p.diagnostics.iter().map(|d| d.code).collect();
assert!(
codes.contains(&"W-TEMPLATE"),
"expected W-TEMPLATE, got {codes:?}"
);
assert!(
!codes.contains(&"U-TABLE"),
"false U-TABLE flag, got {codes:?}"
);
}
#[test]
fn isolates_headings_without_blank_lines() {
let p = parse("Intro text.\n== History ==\nMore text.");
assert!(matches!(p.nodes[0], Node::Paragraph(_)));
assert!(matches!(p.nodes[1], Node::Heading { level: 2, .. }));
assert!(matches!(p.nodes[2], Node::Paragraph(_)));
assert_eq!(
render::plain(&p.nodes),
"Intro text.\n\nHistory\n\nMore text."
);
}
#[test]
fn parses_external_links() {
let p = parse("See [https://nasa.gov NASA] and [https://x.org].");
assert!(p.diagnostics.is_empty(), "diags: {:?}", p.diagnostics);
assert_eq!(render::plain(&p.nodes), "See NASA and .");
}
#[test]
fn drops_nonprose_links() {
let p = parse("a [[File:Pic.jpg|thumb|alt=x|cap]] b");
assert!(p.diagnostics.is_empty(), "diags: {:?}", p.diagnostics);
assert_eq!(render::plain(&p.nodes), "a b");
assert_eq!(
render::plain(&parse("[[Image:Y.png|right|200px]]").nodes),
""
);
assert_eq!(
render::plain(&parse("[[Category:Living people]]").nodes),
""
);
assert_eq!(
render::plain(&parse("x [[Category:1959 births]] y").nodes),
"x y"
);
assert_eq!(
render::plain(&parse("see [[Earth|our planet]]").nodes),
"see our planet"
);
assert_eq!(
render::plain(&parse("[[:Category:Physics|physics]]").nodes),
"physics"
);
}
#[test]
fn unclosed_template_with_trailing_multibyte_does_not_panic() {
assert_eq!(strip_inline_templates("{{a²"), "");
assert_eq!(
strip_inline_templates("{{Infobox\n| mass = 5.97e24 kg ²"),
""
);
assert_eq!(strip_inline_templates("{{t|x}} 8.87 m/s²"), " 8.87 m/s²");
let p = parse("{{a²");
assert!(p.diagnostics.iter().any(|d| d.code == "W-TEMPLATE"));
}
#[test]
fn drops_media_link_with_nested_caption_link() {
assert_eq!(
render::plain(&parse("[[File:Fan.jpg|thumb|A [[wikt:fan|fan]] moves air.]]").nodes),
""
);
let out = render::plain(
&parse("Intro.\n\n[[File:Fan.jpg|thumb|A [[wikt:fan|fan]] moves air.]]\n\nBody.").nodes,
);
assert!(
!out.contains("]]") && !out.contains("moves air"),
"leaked: {out:?}"
);
assert!(
out.contains("Intro.") && out.contains("Body."),
"lost prose: {out:?}"
);
let out =
render::plain(&parse("[[File:X.jpg|thumb|see [[Earth]]]] and [[Mars|planet]]").nodes);
assert!(
!out.contains("]]") && !out.contains("Earth"),
"leaked: {out:?}"
);
assert!(out.contains("planet"), "lost link: {out:?}");
}
#[test]
fn parses_simple_lists() {
let p = parse("* first\n* '''second'''");
assert!(p.diagnostics.is_empty(), "diags: {:?}", p.diagnostics);
assert!(matches!(p.nodes[0], Node::List { ordered: false, .. }));
assert_eq!(render::plain(&p.nodes), "first\nsecond");
let d = parse("; term\n: definition");
assert!(d.diagnostics.is_empty(), "diags: {:?}", d.diagnostics);
assert_eq!(render::plain(&d.nodes), "term\ndefinition");
}
#[test]
fn parses_nested_lists() {
let p = parse("* a\n** b\n** c\n* d");
assert!(p.diagnostics.is_empty(), "diags: {:?}", p.diagnostics);
assert_eq!(render::plain(&p.nodes), "a\nb\nc\nd");
let Node::List {
items,
ordered: false,
} = &p.nodes[0]
else {
panic!("expected unordered List, got {:?}", p.nodes[0]);
};
let nested = items[0]
.iter()
.find_map(|n| match n {
Node::List { items, .. } => Some(items),
_ => None,
})
.expect("first item should hold a sublist");
assert_eq!(nested.len(), 2);
let m = parse("* top\n*# one\n*# two");
assert!(m.diagnostics.is_empty(), "diags: {:?}", m.diagnostics);
let bad = parse("** orphan\n* root");
assert!(
bad.diagnostics.iter().any(|d| d.code == "U-LIST"),
"diags: {:?}",
bad.diagnostics
);
}
#[test]
fn handles_refs_nowiki_comments() {
let p =
parse("Text<ref name=x>cite</ref> and <!-- hidden --> a <nowiki>[[literal]]</nowiki>.");
assert!(p.diagnostics.is_empty(), "diags: {:?}", p.diagnostics);
assert_eq!(render::plain(&p.nodes), "Text and a [[literal]].");
let t = parse("a <table>html</table> b");
assert!(t.diagnostics.iter().any(|d| d.code == "U-HTML"));
}
#[test]
fn keeps_inner_of_transparent_html_tags() {
let p = parse("Use <code>x</code> and <b>'''bold'''</b> and a<br>break.");
assert!(p.diagnostics.is_empty(), "diags: {:?}", p.diagnostics);
assert_eq!(render::plain(&p.nodes), "Use x and bold and a break.");
}
#[test]
fn keeps_inner_of_transparent_block_tags() {
for wt in [
"<div id=\"rock\">HTML rocks</div>",
"<center>'''foo'''</center>",
"<blockquote>a quote</blockquote>",
"<p>para</p>",
] {
let p = parse(wt);
assert!(
p.diagnostics.is_empty(),
"{wt:?} -> diags {:?}",
p.diagnostics
);
}
assert_eq!(
render::plain(&parse("<div id=\"rock\">HTML rocks</div>").nodes),
"HTML rocks"
);
assert_eq!(
render::plain(&parse("<center>'''foo'''</center>").nodes),
"foo"
);
let t = parse("<table><tr><td>x</td></tr></table>");
assert!(
t.diagnostics.iter().any(|d| d.code == "U-HTML"),
"diags: {:?}",
t.diagnostics
);
}
#[test]
fn keeps_inner_of_noinclude_onlyinclude() {
let p = parse("a<noinclude>b</noinclude>c");
assert!(p.diagnostics.is_empty(), "diags: {:?}", p.diagnostics);
assert_eq!(render::plain(&p.nodes), "abc");
let o = parse("Goodbye <onlyinclude>Hello world</onlyinclude>");
assert!(o.diagnostics.is_empty(), "diags: {:?}", o.diagnostics);
assert_eq!(render::plain(&o.nodes), "Goodbye Hello world");
assert!(parse("x<includeonly>y</includeonly>")
.diagnostics
.iter()
.any(|d| d.code == "U-HTML"));
}
#[test]
fn keeps_inner_of_html_lists() {
let p = parse("<ul>\n<li>One</li>\n<li>Two</li>\n</ul>");
assert!(p.diagnostics.is_empty(), "diags: {:?}", p.diagnostics);
assert_eq!(render::plain(&p.nodes), "One\nTwo");
}
#[test]
fn parses_preformatted_blocks() {
let p = parse(" code line one\n code [[link|two]]");
assert!(p.diagnostics.is_empty(), "diags: {:?}", p.diagnostics);
assert!(matches!(p.nodes[0], Node::Preformatted(_)));
assert_eq!(render::plain(&p.nodes), "code line one\ncode two");
}
#[test]
fn parses_simple_tables() {
let p = parse(
"{| class=\"wikitable\"\n|-\n! Name !! Age\n|-\n| Alice || 30\n|-\n| Bob || 25\n|}",
);
assert!(p.diagnostics.is_empty(), "diags: {:?}", p.diagnostics);
assert!(matches!(p.nodes[0], Node::Table { .. }));
assert_eq!(render::plain(&p.nodes), "Name\tAge\nAlice\t30\nBob\t25");
let a = parse("{|\n| style=\"x\" | hi || [[A|link]]\n|}");
assert_eq!(render::plain(&a.nodes), "hi\tlink");
let c = parse("{|\n| cell line one\nstill the cell\n|}");
assert!(c.diagnostics.iter().any(|d| d.code == "U-TABLE"));
}
#[test]
fn table_cell_template_with_pipe_brace_stays_one_table() {
let wt = "{| class=\"wikitable\"\n|-\n! A !! B\n| x || {{frac|1|12|}} || y\n|}";
let out = render::plain(&parse(wt).nodes);
assert!(!out.contains("|}"), "leaked table close: {out:?}");
assert!(
!out.contains("{{") && !out.contains("}}"),
"leaked template: {out:?}"
);
assert!(!out.contains("||"), "leaked raw row markup: {out:?}");
}
#[test]
fn table_header_row_splits_on_both_separators_no_leak() {
let node = parse_table("{|\n! a !! b || c\n|}").expect("parses");
let text = render::plain(std::slice::from_ref(&node));
assert!(!text.contains("||"), "leaked || markup: {text:?}");
assert_eq!(text.trim_end(), "a\tb\tc");
}
#[test]
fn table_with_spanning_cells_bails_honestly() {
let p = parse("{|\n! colspan=2 | Title\n|-\n| a || b\n|}");
assert!(
p.diagnostics.iter().any(|d| d.code == "U-TABLE"),
"colspan grid should bail, got {:?}",
p.diagnostics
);
}
#[test]
fn unclosed_template_drops_to_block_end_not_literal() {
let p = parse("keep {{unclosed\nmore");
let text = render::plain(&p.nodes);
assert!(!text.contains("{{"), "leaked unclosed template: {text:?}");
assert!(text.starts_with("keep"), "lost prose before it: {text:?}");
assert!(!text.contains("more"), "template body leaked: {text:?}");
assert!(
p.diagnostics.iter().any(|d| d.code == "W-TEMPLATE"),
"the drop must stay flagged: {:?}",
p.diagnostics
);
}
#[test]
fn has_tag_ignores_tags_inside_handled_spans() {
let p = parse("a <!-- <table> --> b");
assert!(
p.diagnostics.is_empty(),
"comment body: {:?}",
p.diagnostics
);
assert_eq!(render::plain(&p.nodes), "a b");
let p = parse("see<ref>uses <table> markup</ref> now");
assert!(p.diagnostics.is_empty(), "ref body: {:?}", p.diagnostics);
assert_eq!(render::plain(&p.nodes), "see now");
let p = parse("x <nowiki><table></nowiki> y");
assert!(p.diagnostics.is_empty(), "nowiki body: {:?}", p.diagnostics);
assert_eq!(render::plain(&p.nodes), "x <table> y");
let p = parse("a <table> b");
assert!(
p.diagnostics.iter().any(|d| d.code == "U-HTML"),
"real structural tag must still flag: {:?}",
p.diagnostics
);
}
#[test]
fn uppercase_colspan_grid_still_bails() {
let p = parse("{|\n! COLSPAN=2 | Title\n|-\n| a || b\n|}");
assert!(
p.diagnostics.iter().any(|d| d.code == "U-TABLE"),
"uppercase COLSPAN grid should bail, got {:?}",
p.diagnostics
);
}
#[test]
fn colspan_in_cell_content_is_prose_not_a_bail() {
let p = parse("{|\n| the colspan attribute spans columns\n|-\n| a || b\n|}");
assert!(
p.diagnostics.is_empty(),
"prose mention of colspan must not bail: {:?}",
p.diagnostics
);
let text = render::plain(&p.nodes);
assert!(text.contains("colspan attribute"), "{text:?}");
}
#[test]
fn cell_attr_splitter_is_quote_aware_and_depth_safe() {
assert_eq!(cell_split(r#" data-x="]]" | kept"#).1, "kept");
assert_eq!(cell_split(r#" data-x="}}" | kept"#).1, "kept");
assert_eq!(cell_split(r#" data-x="a|b" | kept"#).1, "kept");
assert_eq!(cell_split("]] junk | kept").1, "kept");
assert_eq!(cell_split("just content"), (None, "just content"));
assert_eq!(cell_split("[[a|b]] rest"), (None, "[[a|b]] rest"));
assert_eq!(
cell_split(r#" style="x" | it's fine"#),
(Some(r#" style="x" "#), "it's fine")
);
}
#[test]
fn table_cell_quoted_attr_closers_do_not_leak() {
let p = parse("{|\n| data-x=\"]]\" | kept || data-y=\"a|b\" | also\n|}");
let text = render::plain(&p.nodes);
assert!(
text.contains("kept") && text.contains("also"),
"lost cell content: {text:?}"
);
assert!(
!text.contains("data-x") && !text.contains("]]"),
"attribute junk leaked as text: {text:?}"
);
}
#[test]
fn parse_table_handles_multiline_ref_in_cell() {
let block = "{| class=\"wikitable\"\n|-\n| Alpha\n| 42<ref>{{cite web |title=x\n|url=y}}</ref>\n|-\n| Beta\n| 7\n|}";
let node = parse_table(block).expect("table should parse, not bail");
let text = render::plain(std::slice::from_ref(&node));
assert!(!text.contains("cite web"), "ref leaked: {text:?}");
assert!(!text.contains("url=y"), "ref param leaked: {text:?}");
assert!(
text.contains("Alpha") && text.contains("Beta"),
"lost cells: {text:?}"
);
assert!(
text.contains("42") && text.contains('7'),
"lost data: {text:?}"
);
}
#[test]
fn table_with_multiline_ref_in_cell_parses_dropping_the_ref() {
let wt = "Intro prose.\n\n{|\n|-\n| Smith <ref name=a>{{cite web\n| url = http://e.com\n| title = T}}</ref>\n| 1974\n|}";
let p = parse(wt);
let text = render::plain(&p.nodes);
assert!(text.contains("Intro prose"), "lost lead prose: {text:?}");
assert!(text.contains("Smith"), "lost cell text: {text:?}");
assert!(text.contains("1974"), "lost cell data: {text:?}");
assert!(!text.contains("url"), "leaked cite markup: {text:?}");
assert!(
!p.diagnostics.iter().any(|d| d.code == "U-TABLE"),
"table should parse now, not bail: {:?}",
p.diagnostics
);
}
#[test]
fn drops_inline_templates_with_warning() {
let p = parse("Real prose with a {{convert|6051|km}} inside.");
assert_eq!(render::plain(&p.nodes), "Real prose with a inside.");
let w = p
.diagnostics
.iter()
.find(|d| d.code == "W-TEMPLATE")
.unwrap();
assert_eq!(w.severity, crate::diag::Severity::Warning);
}
#[test]
fn flags_unsupported_blocks_with_diagnostics() {
let wt = "Intro paragraph.\n\n{{Infobox|x}}\n\n<table>raw block</table>";
let p = parse(wt);
let codes: Vec<_> = p.diagnostics.iter().map(|d| d.code).collect();
assert!(codes.contains(&"W-TEMPLATE"), "codes: {codes:?}"); assert!(codes.contains(&"U-HTML"), "codes: {codes:?}"); assert!(matches!(p.nodes[0], Node::Paragraph(_)));
assert!(p
.nodes
.iter()
.any(|n| matches!(n, Node::Unsupported(s) if s.contains("raw"))));
}
}