use std::collections::BTreeMap;
use carta_ast::{
Alignment, ApiVersion, Attr, Block, Caption, Cell, ColSpec, ColWidth, Document, Format, Inline,
ListAttributes, ListNumberDelim, ListNumberStyle, MathType, MetaValue, QuoteType, Row, Table,
TableBody, TableFoot, TableHead, Target, ToCompactString, slug_gfm, to_plain_text,
};
use carta_core::{Extension, Extensions, Reader, ReaderOptions, Result};
use crate::emoji;
use crate::entities;
use crate::heading_ids;
#[derive(Debug, Default, Clone, Copy)]
pub struct MediawikiReader;
impl Reader for MediawikiReader {
fn read(&self, input: &str, options: &ReaderOptions) -> Result<Document> {
let stripped = strip_comments(&expand_tabs(input));
let (source, behavior_switches) = extract_behavior_switches(&stripped);
let chars: Vec<char> = source.chars().collect();
let mut parser = Parser::new(options);
let mut blocks = parser.parse_blocks(&chars);
if !parser.categories.is_empty() {
let mut inlines: Vec<Inline> = Vec::new();
for (index, category) in parser.categories.drain(..).enumerate() {
if index > 0 {
inlines.push(Inline::Space);
}
inlines.push(category);
}
blocks.push(Block::Para(inlines));
}
let mut meta: BTreeMap<String, MetaValue> = BTreeMap::new();
for switch in behavior_switches {
meta.insert(switch, MetaValue::MetaBool(true));
}
Ok(Document {
api_version: ApiVersion::default(),
meta: meta.into_iter().map(|(k, v)| (k.into(), v)).collect(),
blocks,
})
}
}
struct Parser {
extensions: Extensions,
link_counter: usize,
ids: heading_ids::IdRegistry,
categories: Vec<Inline>,
depth: usize,
}
const MAX_BLOCK_DEPTH: usize = 64;
struct ListItem {
markers: Vec<char>,
content: String,
}
#[derive(PartialEq, Eq, Clone, Copy)]
enum ListKind {
Bullet,
Ordered,
Definition,
}
struct RawCell {
is_header: bool,
align: Alignment,
col_span: i32,
row_span: i32,
attr: Attr,
content: String,
}
struct CellAttrs {
align: Alignment,
col_span: i32,
row_span: i32,
attr: Attr,
}
#[derive(Clone, Copy)]
enum OpenTarget {
None,
Caption,
Cell,
}
enum Tok {
Inline(Inline),
Apostrophes(usize),
BlockRaw(String),
BlockBreak,
Block(Block),
}
enum HtmlTagRole {
Inline,
Block,
Break,
}
impl Parser {
fn new(options: &ReaderOptions) -> Self {
Self {
extensions: options.extensions,
link_counter: 0,
ids: heading_ids::IdRegistry::default(),
categories: Vec::new(),
depth: 0,
}
}
fn smart(&self) -> bool {
self.extensions.contains(Extension::Smart)
}
fn parse_blocks(&mut self, chars: &[char]) -> Vec<Block> {
self.depth += 1;
if self.depth > MAX_BLOCK_DEPTH {
self.depth -= 1;
return degraded_blocks(chars);
}
let blocks = self.parse_blocks_inner(chars);
self.depth -= 1;
blocks
}
fn parse_blocks_inner(&mut self, chars: &[char]) -> Vec<Block> {
let mut blocks: Vec<Block> = Vec::new();
let mut pos = 0;
let mut line_start = true;
let n = chars.len();
let mut scan = HeaderScan::default();
while pos < n {
if line_start {
let le = line_end(chars, pos);
if is_blank(chars, pos, le) {
pos = if le < n { le + 1 } else { le };
continue;
}
let c = at(chars, pos).unwrap_or(' ');
if c == '{'
&& at(chars, pos + 1) == Some('{')
&& template_opens(chars, pos)
&& let Some(after) = balanced_braces(chars, pos)
{
let raw = collect_range(chars, pos, after);
blocks.push(Block::RawBlock(format_mediawiki(), raw.into()));
let (np, ls) = finish_inline_block(chars, after);
pos = np;
line_start = ls;
continue;
}
if c == '{' && at(chars, pos + 1) == Some('|') {
let (block, after) = self.parse_table(chars, pos);
blocks.push(block);
let (np, ls) = finish_inline_block(chars, after);
pos = np;
line_start = ls;
continue;
}
if c == '='
&& let Some((level, inlines, closer_end)) =
self.try_header(chars, pos, &mut scan)
{
let id = self.make_id(&inlines);
let attr = Attr {
id: id.into(),
classes: Vec::new(),
attributes: Vec::new(),
};
blocks.push(Block::Header(level, Box::new(attr), inlines));
let (np, ls) = finish_inline_block(chars, closer_end);
pos = np;
line_start = ls;
continue;
}
if c == '-' && is_hr_line(chars, pos) {
blocks.push(Block::HorizontalRule);
let le2 = line_end(chars, pos);
pos = if le2 < n { le2 + 1 } else { le2 };
line_start = true;
continue;
}
if matches!(c, '*' | '#' | ':' | ';') && list_run_uniform(chars, pos) {
let (list_blocks, after) = self.parse_list(chars, pos);
blocks.extend(list_blocks);
pos = after;
line_start = true;
continue;
}
if c == ' ' {
let (block, after) = self.parse_preformatted(chars, pos);
blocks.push(block);
pos = after;
line_start = true;
continue;
}
if c == '<'
&& let Some((block, after)) = self.parse_block_tag(chars, pos)
{
blocks.push(block);
let (np, ls) = finish_inline_block(chars, after);
pos = np;
line_start = ls;
continue;
}
}
let (mut para_blocks, after) = self.parse_paragraph(chars, pos, &mut scan);
blocks.append(&mut para_blocks);
pos = after;
line_start = true;
}
blocks
}
fn try_header(
&mut self,
chars: &[char],
pos: usize,
scan: &mut HeaderScan,
) -> Option<(i32, Vec<Inline>, usize)> {
let le = line_end(chars, pos);
let mut m = 0;
while pos + m < le && at(chars, pos + m) == Some('=') {
m += 1;
}
if m == 0 || m > 6 {
return None;
}
let content_start = pos + m;
let region_end = header_region_end_scan(chars, pos, scan);
let closer = header_closer(chars, content_start, region_end, m)?;
let content = collect_range(chars, content_start, closer);
let inlines = self.parse_inlines(content.trim());
Some((i32::try_from(m).unwrap_or(1), inlines, closer + m))
}
fn parse_list(&mut self, chars: &[char], pos: usize) -> (Vec<Block>, usize) {
let mut items: Vec<ListItem> = Vec::new();
let mut cursor = pos;
let n = chars.len();
while at(chars, cursor).is_some_and(is_list_marker) {
let le = line_end(chars, cursor);
let mut scan = cursor;
let mut markers: Vec<char> = Vec::new();
while scan < le && at(chars, scan).is_some_and(is_list_marker) {
if let Some(marker) = at(chars, scan) {
markers.push(marker);
}
scan += 1;
}
let content = collect_range(chars, scan, le).trim().to_string();
items.push(ListItem { markers, content });
if le >= n {
cursor = le;
break;
}
cursor = le + 1;
}
(self.build_lists(&items, 0), cursor)
}
fn build_lists(&mut self, items: &[ListItem], level: usize) -> Vec<Block> {
if level >= MAX_BLOCK_DEPTH {
let mut out: Vec<Block> = Vec::new();
for item in items {
let inlines = self.parse_inlines(&item.content);
if !inlines.is_empty() {
out.push(Block::Plain(inlines));
}
}
return out;
}
let mut out: Vec<Block> = Vec::new();
let mut i = 0;
while i < items.len() {
let kind = if let Some(&m) = items.get(i).and_then(|it| it.markers.get(level)) {
list_kind(m)
} else {
i += 1;
continue;
};
let mut j = i;
while j < items.len() {
match items.get(j).and_then(|it| it.markers.get(level)) {
Some(&m) if list_kind(m) == kind => j += 1,
_ => break,
}
}
let group = items.get(i..j).unwrap_or(&[]);
match kind {
ListKind::Bullet => out.push(Block::BulletList(self.build_simple(group, level))),
ListKind::Ordered => {
out.push(Block::OrderedList(
default_list_attrs(),
self.build_simple(group, level),
));
}
ListKind::Definition => out.push(self.build_definition(group, level)),
}
i = j;
}
out
}
fn build_simple(&mut self, group: &[ListItem], level: usize) -> Vec<Vec<Block>> {
let mut entries: Vec<Vec<Block>> = Vec::new();
let mut i = 0;
while i < group.len() {
let depth = group.get(i).map_or(0, |it| it.markers.len());
if depth == level + 1 {
let content = group.get(i).map_or("", |it| it.content.as_str());
let mut blocks = vec![plain_or_figure(self.parse_inlines(content))];
i += 1;
let start = i;
while i < group.len() && group.get(i).map_or(0, |it| it.markers.len()) > level + 1 {
i += 1;
}
if let Some(sub) = group.get(start..i)
&& !sub.is_empty()
{
blocks.extend(self.build_lists(sub, level + 1));
}
entries.push(blocks);
} else {
let start = i;
while i < group.len() && group.get(i).map_or(0, |it| it.markers.len()) > level + 1 {
i += 1;
}
if i == start {
i += 1;
}
let blocks = group
.get(start..i)
.map(|sub| self.build_lists(sub, level + 1))
.unwrap_or_default();
entries.push(blocks);
}
}
entries
}
fn build_definition(&mut self, group: &[ListItem], level: usize) -> Block {
let mut pairs: Vec<(Vec<Inline>, Vec<Vec<Block>>)> = Vec::new();
let mut i = 0;
while i < group.len() {
let Some(item) = group.get(i) else { break };
if item.markers.len() == level + 1 {
let marker = item.markers.get(level).copied().unwrap_or(':');
let content = item.content.clone();
i += 1;
let start = i;
while i < group.len() && group.get(i).map_or(0, |it| it.markers.len()) > level + 1 {
i += 1;
}
let nested = group
.get(start..i)
.map(|sub| self.build_lists(sub, level + 1))
.unwrap_or_default();
if marker == ';' {
let (term_str, def_str) = split_term(&content);
let term = self.parse_inlines(&term_str);
let mut defs: Vec<Vec<Block>> = Vec::new();
if let Some(d) = def_str {
defs.push(vec![plain_or_figure(self.parse_inlines(&d))]);
}
if !nested.is_empty() {
match defs.last_mut() {
Some(last) => last.extend(nested),
None => defs.push(nested),
}
}
match pairs.last_mut() {
Some((last_term, last_defs)) if last_defs.is_empty() => {
last_term.push(Inline::LineBreak);
last_term.extend(term);
*last_defs = defs;
}
_ => pairs.push((term, defs)),
}
} else {
let mut blocks = vec![plain_or_figure(self.parse_inlines(&content))];
blocks.extend(nested);
match pairs.last_mut() {
Some(last) => last.1.push(blocks),
None => pairs.push((Vec::new(), vec![blocks])),
}
}
} else {
let start = i;
while i < group.len() && group.get(i).map_or(0, |it| it.markers.len()) > level + 1 {
i += 1;
}
if i == start {
i += 1;
}
let nested = group
.get(start..i)
.map(|sub| self.build_lists(sub, level + 1))
.unwrap_or_default();
match pairs.last_mut() {
Some(last) => match last.1.last_mut() {
Some(d) => d.extend(nested),
None => last.1.push(nested),
},
None => pairs.push((Vec::new(), vec![nested])),
}
}
}
Block::DefinitionList(pairs)
}
fn parse_preformatted(&mut self, chars: &[char], pos: usize) -> (Block, usize) {
let n = chars.len();
let mut p = pos;
let mut lines: Vec<Vec<Inline>> = Vec::new();
while at(chars, p) == Some(' ') {
let le = line_end(chars, p);
let content = collect_range(chars, p + 1, le);
lines.push(self.preformatted_line(&content));
if le >= n {
p = le;
break;
}
p = le + 1;
}
let mut out: Vec<Inline> = Vec::new();
for (idx, mut inlines) in lines.into_iter().enumerate() {
if idx > 0 {
out.push(Inline::LineBreak);
}
out.append(&mut inlines);
}
(Block::Para(out), p)
}
fn parse_block_tag(&mut self, chars: &[char], pos: usize) -> Option<(Block, usize)> {
let (name, raw_open, self_closing, after_open) = open_tag(chars, pos)?;
match name.as_str() {
"blockquote" => {
if self_closing {
return Some((Block::BlockQuote(Vec::new()), after_open));
}
let (inner, after) = enclosed(chars, after_open, "blockquote");
let inner_chars: Vec<char> = inner.chars().collect();
Some((Block::BlockQuote(self.parse_blocks(&inner_chars)), after))
}
"pre" => {
let (inner, after) = enclosed(chars, after_open, "pre");
Some((
Block::CodeBlock(Box::default(), trim_code(&inner).into()),
after,
))
}
"source" | "syntaxhighlight" => {
let (inner, after) = enclosed(chars, after_open, &name);
let mut classes = Vec::new();
if let Some(lang) = tag_attribute(&raw_open, "lang")
&& !lang.is_empty()
{
classes.push(lang.into());
}
let attr = Attr {
id: carta_ast::Text::default(),
classes,
attributes: Vec::new(),
};
Some((
Block::CodeBlock(Box::new(attr), trim_code(&inner).into()),
after,
))
}
"ul" => Some(self.parse_html_list(chars, after_open, false, &raw_open, self_closing)),
"ol" => Some(self.parse_html_list(chars, after_open, true, &raw_open, self_closing)),
_ => None,
}
}
fn parse_html_list(
&mut self,
chars: &[char],
start: usize,
ordered: bool,
raw_open: &str,
self_closing: bool,
) -> (Block, usize) {
let mut items: Vec<Vec<Block>> = Vec::new();
let mut i = start;
let close_name = if ordered { "ol" } else { "ul" };
if !self_closing {
loop {
while at(chars, i).is_some_and(char::is_whitespace) {
i += 1;
}
if at(chars, i) == Some('<')
&& at(chars, i + 1) == Some('/')
&& tag_name_matches(chars, i + 2, close_name)
&& let Some((_, _, after)) = close_tag_parse(chars, i)
{
i = after;
break;
}
if at(chars, i) == Some('<')
&& at(chars, i + 1) != Some('/')
&& tag_name_matches(chars, i + 1, "li")
&& let Some((_, _, _self_closing, after_li)) = open_tag(chars, i)
{
let (content_end, next) = html_li_content_bounds(chars, after_li);
let content: Vec<char> = collect_range(chars, after_li, content_end)
.chars()
.collect();
let mut blocks = self.parse_blocks(&content);
if let Some(Block::Para(inlines)) = blocks.first() {
let inlines = inlines.clone();
if let Some(first) = blocks.first_mut() {
*first = Block::Plain(inlines);
}
}
items.push(blocks);
i = next;
continue;
}
break;
}
}
let block = if ordered {
let start_num = tag_attribute(raw_open, "start")
.and_then(|value| value.trim().parse::<i32>().ok())
.unwrap_or(1);
Block::OrderedList(
ListAttributes {
start: start_num,
style: ListNumberStyle::DefaultStyle,
delim: ListNumberDelim::DefaultDelim,
},
items,
)
} else {
Block::BulletList(items)
};
(block, i)
}
fn parse_paragraph(
&mut self,
chars: &[char],
pos: usize,
scan: &mut HeaderScan,
) -> (Vec<Block>, usize) {
let n = chars.len();
let mut pieces: Vec<String> = Vec::new();
let mut cur = pos;
loop {
let le = line_end(chars, cur);
pieces.push(collect_range(chars, cur, le));
if le >= n {
cur = le;
break;
}
let next = le + 1;
if next >= n {
cur = next;
break;
}
let ref_open = open_ref_depth(chars, pos, next) > 0;
let next_end = line_end(chars, next);
if is_blank(chars, next, next_end) {
if ref_open {
cur = next;
continue;
}
cur = if next_end < n { next_end + 1 } else { next_end };
break;
}
if line_starts_block_scan(chars, next, scan) {
if ref_open && open_ref_block_bodied(chars, pos, next) {
cur = next;
continue;
}
cur = next;
break;
}
cur = next;
}
let raw = pieces.join("\n");
let trimmed = raw.trim();
if trimmed.is_empty() {
return (Vec::new(), cur);
}
(self.parse_block_content(trimmed), cur)
}
fn parse_block_content(&mut self, text: &str) -> Vec<Block> {
let chars: Vec<char> = text.chars().collect();
let toks = self.lex(&chars, false, true);
let smart = self.smart();
let east_asian = self.extensions.contains(Extension::EastAsianLineBreaks);
let mut blocks: Vec<Block> = Vec::new();
let mut segment: Vec<Tok> = Vec::new();
for tok in toks {
match tok {
Tok::BlockRaw(raw) => {
flush_para_segment(&mut segment, &mut blocks, smart, east_asian);
blocks.push(Block::RawBlock(format_html(), raw.into()));
}
Tok::Block(block) => {
flush_para_segment(&mut segment, &mut blocks, smart, east_asian);
blocks.push(block);
}
Tok::BlockBreak => flush_para_segment(&mut segment, &mut blocks, smart, east_asian),
other => segment.push(other),
}
}
flush_para_segment(&mut segment, &mut blocks, smart, east_asian);
blocks
}
fn parse_table(&mut self, chars: &[char], pos: usize) -> (Block, usize) {
let after = table_block_end(chars, pos);
let region = collect_range(chars, pos, after);
(self.build_table(®ion), after)
}
fn build_table(&mut self, region: &str) -> Block {
let (mut rows, caption_text) = scan_table_region(region);
if rows.first().is_some_and(Vec::is_empty) {
rows.remove(0);
}
if rows.is_empty() {
rows.push(Vec::new());
}
let n_rows = rows.len();
let ncols = rows.first().map_or(0, |r| {
r.iter().map(|c| col_count(c.col_span)).sum::<usize>()
});
let col_specs = column_specs(&rows, ncols);
let is_header_first = rows
.first()
.and_then(|r| r.first())
.is_some_and(|c| c.is_header);
let ast_rows = self.lay_grid(&rows, ncols, n_rows);
let (head_rows, body_rows) = if is_header_first {
let mut iter = ast_rows.into_iter();
let head: Vec<Row> = iter.next().into_iter().collect();
(head, iter.collect::<Vec<Row>>())
} else {
(Vec::new(), ast_rows)
};
let caption = match caption_text {
Some(text) => {
let inlines = self.parse_inlines(text.trim());
if inlines.is_empty() {
Caption::default()
} else {
Caption {
short: None,
long: vec![Block::Plain(inlines)],
}
}
}
None => Caption::default(),
};
Block::Table(Box::new(Table {
attr: Attr::default(),
caption,
col_specs,
head: TableHead {
attr: Attr::default(),
rows: head_rows,
},
bodies: vec![TableBody {
attr: Attr::default(),
row_head_columns: 0,
head: Vec::new(),
body: body_rows,
}],
foot: TableFoot::default(),
}))
}
fn lay_grid(&mut self, rows: &[Vec<RawCell>], ncols: usize, n_rows: usize) -> Vec<Row> {
let mut ast_rows: Vec<Row> = Vec::new();
let mut occupied: Vec<i32> = vec![0; ncols];
for (r, raw) in rows.iter().enumerate() {
let available = i32::try_from(n_rows.saturating_sub(r)).unwrap_or(i32::MAX);
let mut cells: Vec<Cell> = Vec::new();
let mut col = 0usize;
for c in raw {
while col < ncols && occupied.get(col).copied().unwrap_or(0) > 0 {
col += 1;
}
if col >= ncols {
break;
}
let col_span = col_count(c.col_span).min(ncols - col);
let row_span = c.row_span.max(1).min(available);
let content_chars: Vec<char> = c.content.trim().chars().collect();
let content = self.parse_cell_blocks(&content_chars);
cells.push(Cell {
attr: c.attr.clone(),
align: c.align.clone(),
row_span,
col_span: i32::try_from(col_span).unwrap_or(1),
content,
});
for k in col..col + col_span {
if let Some(slot) = occupied.get_mut(k) {
*slot = row_span;
}
}
col += col_span;
}
while col < ncols {
if occupied.get(col).copied().unwrap_or(0) == 0 {
cells.push(empty_cell());
}
col += 1;
}
for slot in &mut occupied {
*slot = (*slot - 1).max(0);
}
ast_rows.push(Row {
attr: Attr::default(),
cells,
});
}
ast_rows
}
fn parse_cell_blocks(&mut self, chars: &[char]) -> Vec<Block> {
let first = at(chars, 0);
let suppressed = matches!(first, Some('*' | '#' | ';'))
|| (first == Some('=') && is_header_line_within(chars, 0));
if !suppressed {
return self.parse_blocks(chars);
}
let (mut blocks, after) = self.parse_paragraph(chars, 0, &mut HeaderScan::default());
if let Some(rest) = chars.get(after..) {
blocks.extend(self.parse_blocks(rest));
}
blocks
}
fn note_blocks(&mut self, chars: &[char]) -> Vec<Block> {
let blocks = self.parse_blocks(chars);
match blocks.as_slice() {
[Block::Para(inlines)] => vec![Block::Plain(inlines.clone())],
_ => blocks,
}
}
fn parse_inlines(&mut self, text: &str) -> Vec<Inline> {
let chars: Vec<char> = text.chars().collect();
let toks = self.lex(&chars, false, false);
let mut inlines = coalesce(resolve_emphasis(toks));
if self.extensions.contains(Extension::EastAsianLineBreaks) {
inlines = drop_east_asian_breaks(inlines);
}
if self.smart() {
inlines = apply_smart_quotes(inlines);
}
inlines
}
fn preformatted_line(&mut self, text: &str) -> Vec<Inline> {
let chars: Vec<char> = text.chars().collect();
let toks = self.lex(&chars, true, false);
preformat_transform(resolve_emphasis(toks))
}
#[allow(clippy::too_many_lines)]
fn lex(&mut self, chars: &[char], preformatted: bool, block_context: bool) -> Vec<Tok> {
let mut toks: Vec<Tok> = Vec::new();
let mut word = String::new();
let mut i = 0;
let n = chars.len();
while i < n {
let Some(c) = at(chars, i) else { break };
if c == '\'' {
let mut end = i;
while at(chars, end) == Some('\'') {
end += 1;
}
let run = end - i;
if run >= 2 {
flush_word(&mut word, &mut toks);
toks.push(Tok::Apostrophes(run));
} else {
word.push('\'');
}
i = end;
continue;
}
if c.is_whitespace() {
if preformatted {
word.push(c);
i += 1;
continue;
}
flush_word(&mut word, &mut toks);
let (token, next) = whitespace_token(chars, i);
toks.push(Tok::Inline(token));
i = next;
continue;
}
if c == '&' {
if let Some((decoded, next)) = entities::read_reference(chars, i, chars.len(), true)
{
word.push_str(&decoded);
i = next;
} else {
word.push('&');
i += 1;
}
continue;
}
if c == '<' {
if let Some((inlines, next)) = self.handle_tag(chars, i) {
flush_word(&mut word, &mut toks);
for inline in inlines {
toks.push(Tok::Inline(inline));
}
i = next;
continue;
}
if block_context
&& starts_block_tag(chars, i)
&& let Some((block, next)) = self.parse_block_tag(chars, i)
{
flush_word(&mut word, &mut toks);
toks.push(Tok::Block(block));
i = next;
continue;
}
if let Some((tok, next)) = block_tag_token(chars, i) {
flush_word(&mut word, &mut toks);
toks.push(tok);
i = next;
continue;
}
word.push('<');
i += 1;
continue;
}
if c == '{' && at(chars, i + 1) == Some('{') {
if template_opens(chars, i)
&& let Some(after) = balanced_braces(chars, i)
{
flush_word(&mut word, &mut toks);
let raw = collect_range(chars, i, after);
toks.push(Tok::Inline(Inline::RawInline(
format_mediawiki(),
raw.into(),
)));
i = after;
continue;
}
word.push('{');
i += 1;
continue;
}
if c == '[' {
let handled = if at(chars, i + 1) == Some('[') {
self.internal_link(chars, i)
} else {
self.external_link(chars, i)
};
if let Some((inlines, next)) = handled {
flush_word(&mut word, &mut toks);
for inline in inlines {
toks.push(Tok::Inline(inline));
}
i = next;
continue;
}
if at(chars, i + 1) != Some('[')
&& let Some((inline, next)) = bare_url(chars, i + 1)
{
word.push('[');
flush_word(&mut word, &mut toks);
toks.push(Tok::Inline(inline));
i = next;
continue;
}
word.push('[');
i += 1;
continue;
}
if word.is_empty()
&& let Some((inline, next)) = bare_url(chars, i)
{
toks.push(Tok::Inline(inline));
i = next;
continue;
}
word.push(c);
i += 1;
}
flush_word(&mut word, &mut toks);
toks
}
#[allow(clippy::too_many_lines)]
fn handle_tag(&mut self, chars: &[char], i: usize) -> Option<(Vec<Inline>, usize)> {
if at(chars, i) != Some('<') {
return None;
}
match at(chars, i + 1) {
Some('/') => {
let (name, raw, after) = close_tag_parse(chars, i)?;
return match html_tag_role(&name) {
Some(HtmlTagRole::Inline) => Some((vec![raw_html(raw)], after)),
_ => None,
};
}
Some(c) if c.is_ascii_alphabetic() => {}
_ => return None,
}
let (name, raw_open, self_closing, after_open) = open_tag(chars, i)?;
match name.as_str() {
"br" => Some((vec![Inline::LineBreak], after_open)),
"ref" => {
if self_closing {
return Some((vec![Inline::Note(Vec::new())], after_open));
}
match close_tag(chars, after_open, "ref") {
Some((inner_end, after)) => {
let inner = collect_range(chars, after_open, inner_end);
let inner_chars: Vec<char> = inner.chars().collect();
Some((vec![Inline::Note(self.note_blocks(&inner_chars))], after))
}
None => Some((vec![raw_html(raw_open)], after_open)),
}
}
"nowiki" => {
if self_closing {
return Some((Vec::new(), after_open));
}
let (inner, after) = enclosed(chars, after_open, "nowiki");
Some((plain_inlines(&inner), after))
}
"math" => {
if self_closing {
return Some((Vec::new(), after_open));
}
match close_tag(chars, after_open, "math") {
Some((inner_end, after)) => {
let inner = collect_range(chars, after_open, inner_end);
Some((
vec![Inline::Math(MathType::InlineMath, inner.trim().into())],
after,
))
}
None => Some((vec![raw_html(raw_open)], after_open)),
}
}
"code" | "tt" => Some(verbatim_code(
chars,
&name,
after_open,
&raw_open,
self_closing,
&[],
)),
"var" => Some(verbatim_code(
chars,
"var",
after_open,
&raw_open,
self_closing,
&["variable"],
)),
"samp" => Some(verbatim_code(
chars,
"samp",
after_open,
&raw_open,
self_closing,
&["sample"],
)),
"sub" => Some(self.wrap(
chars,
"sub",
after_open,
&raw_open,
self_closing,
Inline::Subscript,
)),
"sup" => Some(self.wrap(
chars,
"sup",
after_open,
&raw_open,
self_closing,
Inline::Superscript,
)),
"del" | "strike" => Some(self.wrap(
chars,
&name,
after_open,
&raw_open,
self_closing,
Inline::Strikeout,
)),
"kbd" => Some(self.span(chars, "kbd", after_open, &raw_open, self_closing, "kbd")),
"mark" => Some(self.span(chars, "mark", after_open, &raw_open, self_closing, "mark")),
_ => match html_tag_role(&name) {
Some(HtmlTagRole::Inline) => {
if self_closing {
return Some((vec![raw_html(raw_open)], after_open));
}
match close_tag(chars, after_open, &name) {
Some((inner_end, after)) => {
let inner = collect_range(chars, after_open, inner_end);
let close_raw = collect_range(chars, inner_end, after);
let mut out = vec![raw_html(raw_open)];
out.extend(self.parse_inlines(&inner));
out.push(raw_html(close_raw));
Some((out, after))
}
None => Some((vec![raw_html(raw_open)], after_open)),
}
}
_ => None,
},
}
}
fn wrap(
&mut self,
chars: &[char],
name: &str,
after_open: usize,
raw_open: &str,
self_closing: bool,
ctor: fn(Vec<Inline>) -> Inline,
) -> (Vec<Inline>, usize) {
if self_closing {
return (vec![raw_html(raw_open.to_string())], after_open);
}
match close_tag(chars, after_open, name) {
Some((inner_end, after)) => {
let inner = collect_range(chars, after_open, inner_end);
(vec![ctor(self.parse_inlines(&inner))], after)
}
None => (vec![raw_html(raw_open.to_string())], after_open),
}
}
fn span(
&mut self,
chars: &[char],
name: &str,
after_open: usize,
raw_open: &str,
self_closing: bool,
class: &str,
) -> (Vec<Inline>, usize) {
if self_closing {
return (vec![raw_html(raw_open.to_string())], after_open);
}
match close_tag(chars, after_open, name) {
Some((inner_end, after)) => {
let inner = collect_range(chars, after_open, inner_end);
let attr = Attr {
id: carta_ast::Text::default(),
classes: vec![class.into()],
attributes: Vec::new(),
};
(
vec![Inline::Span(Box::new(attr), self.parse_inlines(&inner))],
after,
)
}
None => (vec![raw_html(raw_open.to_string())], after_open),
}
}
fn external_link(&mut self, chars: &[char], i: usize) -> Option<(Vec<Inline>, usize)> {
let close = find_char(chars, i + 1, ']')?;
let inner = collect_range(chars, i + 1, close);
let (url, label) = match inner.split_once(|c: char| c.is_whitespace()) {
Some((u, rest)) => (u.to_string(), rest.trim_start().to_string()),
None => (inner.clone(), String::new()),
};
if !is_url(&url) {
return None;
}
if label.is_empty() && at(chars, close + 1).is_some_and(char::is_alphanumeric) {
return None;
}
let text = if label.is_empty() {
self.link_counter += 1;
vec![Inline::Str(self.link_counter.to_compact_string())]
} else {
self.parse_inlines(&label)
};
Some((
vec![Inline::Link(
Box::default(),
text,
Box::new(Target {
url: encode_url_target(&url).into(),
title: carta_ast::Text::default(),
}),
)],
close + 1,
))
}
fn internal_link(&mut self, chars: &[char], i: usize) -> Option<(Vec<Inline>, usize)> {
let start = i + 2;
let (target_end, has_pipe) = scan_link_target(chars, start)?;
let target = collect_range(chars, start, target_end).trim().to_string();
let (label_content, close) = if has_pipe {
let label_start = target_end + 1;
let close = find_link_close(chars, label_start)?;
(Some(collect_range(chars, label_start, close)), close)
} else {
(None, target_end)
};
if let Some(ns) = namespace_of(&target) {
if ns == "category" {
let text = match &label_content {
Some(label) if !label.trim().is_empty() => self.parse_inlines(label),
_ => self.parse_inlines(&target),
};
let title = title_text(&text);
let attr = Attr {
id: carta_ast::Text::default(),
classes: vec!["wikilink".into()],
attributes: Vec::new(),
};
self.categories.push(Inline::Link(
Box::new(attr),
text,
Box::new(Target {
url: wikilink_url(&target).into(),
title: title.into(),
}),
));
return Some((Vec::new(), close + 2));
}
if matches!(ns.as_str(), "file" | "image")
&& !strip_namespace(&target).is_empty()
&& let Some(image) = self.image_embed(&target, label_content.as_deref())
{
return Some((vec![image], close + 2));
}
}
let mut after = close + 2;
let mut trail = String::new();
while let Some(c) = at(chars, after) {
if c.is_ascii_alphabetic() {
trail.push(c);
after += 1;
} else {
break;
}
}
let mut label = match &label_content {
Some(l) if l.trim().is_empty() => self.pipe_trick_label(&target),
Some(l) => self.parse_inlines(l),
None => self.parse_inlines(&target),
};
let title = title_text(&label);
if !trail.is_empty() {
label.push(Inline::Str(trail.into()));
label = coalesce(label);
}
let attr = Attr {
id: carta_ast::Text::default(),
classes: vec!["wikilink".into()],
attributes: Vec::new(),
};
let url = wikilink_url(&target);
Some((
vec![Inline::Link(
Box::new(attr),
label,
Box::new(Target {
url: url.into(),
title: title.into(),
}),
)],
after,
))
}
fn pipe_trick_label(&mut self, target: &str) -> Vec<Inline> {
match target.split_once(':') {
Some((_, rest)) => self.parse_inlines(rest),
None => Vec::new(),
}
}
fn image_embed(&mut self, target: &str, params: Option<&str>) -> Option<Inline> {
let url = wikilink_url(strip_namespace(target));
let mut attributes: Vec<(String, String)> = Vec::new();
let mut caption: Option<String> = None;
if let Some(params) = params {
for part in params.split('|') {
let option = part.trim();
if image_param_declines(option) {
return None;
}
if let Some((width, height)) = image_size(option) {
attributes.retain(|(key, _)| key != "width" && key != "height");
attributes.push(("width".to_string(), width));
if let Some(height) = height {
attributes.push(("height".to_string(), height));
}
} else if is_image_keyword(option) || is_recognized_image_attr(option) {
} else {
caption = Some(part.to_string());
}
}
}
let caption = caption.unwrap_or_else(|| url.clone());
let alt = self.parse_inlines(&caption);
let title = title_text(&alt);
let attr = Attr {
id: carta_ast::Text::default(),
classes: Vec::new(),
attributes: attributes
.into_iter()
.map(|(k, v)| (k.into(), v.into()))
.collect(),
};
Some(Inline::Image(
Box::new(attr),
alt,
Box::new(Target {
url: url.into(),
title: title.into(),
}),
))
}
fn make_id(&mut self, inlines: &[Inline]) -> String {
let plain = to_plain_text(inlines);
if self.extensions.contains(Extension::GfmAutoIdentifiers) {
let base = self.finish_id(slug_gfm, &emoji_to_aliases(&plain));
self.ids.assign_with_separator(base, '-')
} else if self.extensions.contains(Extension::AutoIdentifiers) {
let base = self.finish_id(mediawiki_slug, &plain);
self.ids.assign_with_separator(base, '_')
} else {
String::new()
}
}
fn finish_id(&self, slug: fn(&str) -> String, source: &str) -> String {
let mut base = slug(source);
if self.extensions.contains(Extension::AsciiIdentifiers) {
base = slug(&transliterate_ascii(&base));
}
base
}
}
fn expand_tabs(input: &str) -> String {
if !input.contains('\t') {
return input.to_string();
}
let mut out = String::with_capacity(input.len());
let mut col = 0usize;
for ch in input.chars() {
match ch {
'\t' => {
let spaces = 4 - (col % 4);
for _ in 0..spaces {
out.push(' ');
}
col += spaces;
}
'\n' => {
out.push('\n');
col = 0;
}
other => {
out.push(other);
col += 1;
}
}
}
out
}
fn strip_comments(input: &str) -> String {
let chars: Vec<char> = input.chars().collect();
let n = chars.len();
let mut out = String::new();
let mut i = 0;
while i < n {
let Some(c) = at(&chars, i) else { break };
if c == '<' {
if let Some(after) = verbatim_region_end(&chars, i) {
out.push_str(&collect_range(&chars, i, after));
i = after;
continue;
}
if matches_prefix_ci(&chars, i, "<!--") {
if let Some(dash) = find_seq(&chars, i + 4, &['-', '-', '>']) {
let comment_end = dash + 3;
let preceded = i == 0 || at(&chars, i - 1) == Some('\n');
let followed = comment_end >= n || at(&chars, comment_end) == Some('\n');
if preceded && followed {
i = if comment_end < n {
comment_end + 1
} else {
comment_end
};
} else if preceded || followed {
i = comment_end;
} else {
out.push(' ');
i = comment_end;
}
continue;
}
out.push('<');
i += 1;
continue;
}
}
out.push(c);
i += 1;
}
out
}
fn verbatim_region_end(chars: &[char], i: usize) -> Option<usize> {
let (name, _raw, self_closing, after_open) = open_tag(chars, i)?;
if !matches!(
name.as_str(),
"pre" | "nowiki" | "math" | "source" | "syntaxhighlight"
) {
return None;
}
if self_closing {
return Some(after_open);
}
match close_tag(chars, after_open, &name) {
Some((_, after)) => Some(after),
None => Some(chars.len()),
}
}
const BEHAVIOR_SWITCHES: &[&str] = &[
"ARCHIVEDTALK",
"DISAMBIG",
"EXPECTUNUSEDCATEGORY",
"EXPECTUNUSEDTEMPLATE",
"FORCETOC",
"HIDDENCAT",
"INDEX",
"NEWSECTIONLINK",
"NOCC",
"NOCONTENTCONVERT",
"NOEDITSECTION",
"NOGALLERY",
"NOGLOBAL",
"NOINDEX",
"NONEWSECTIONLINK",
"NOTC",
"NOTITLECONVERT",
"NOTOC",
"STATICREDIRECT",
"TOC",
];
fn extract_behavior_switches(input: &str) -> (String, Vec<String>) {
let chars: Vec<char> = input.chars().collect();
let n = chars.len();
let mut out = String::new();
let mut found: Vec<String> = Vec::new();
let mut i = 0;
while i < n {
if at(&chars, i) == Some('<')
&& let Some(after) = verbatim_region_end(&chars, i)
{
out.push_str(&collect_range(&chars, i, after));
i = after;
continue;
}
if at(&chars, i) == Some('_')
&& at(&chars, i + 1) == Some('_')
&& let Some((word, after)) = behavior_switch_at(&chars, i)
{
let key = word.to_ascii_lowercase();
if !found.contains(&key) {
found.push(key);
}
i = after;
if out.is_empty() || out.ends_with('\n') {
while matches!(at(&chars, i), Some(' ' | '\t')) {
i += 1;
}
}
continue;
}
if let Some(c) = at(&chars, i) {
out.push(c);
}
i += 1;
}
(out, found)
}
fn behavior_switch_at(chars: &[char], i: usize) -> Option<(String, usize)> {
let start = i + 2;
let mut j = start;
while at(chars, j).is_some_and(|c| c.is_ascii_uppercase()) {
j += 1;
}
let word = collect_range(chars, start, j);
if word.is_empty()
|| at(chars, j) != Some('_')
|| at(chars, j + 1) != Some('_')
|| !BEHAVIOR_SWITCHES.contains(&word.as_str())
{
return None;
}
Some((word, j + 2))
}
enum Unit {
Apostrophe,
Node(Inline),
}
fn resolve_emphasis(toks: Vec<Tok>) -> Vec<Inline> {
let mut units: Vec<Unit> = Vec::new();
for tok in toks {
match tok {
Tok::Inline(inline) => units.push(Unit::Node(inline)),
Tok::Apostrophes(n) => units.extend((0..n).map(|_| Unit::Apostrophe)),
Tok::BlockRaw(raw) => units.push(Unit::Node(raw_html(raw))),
Tok::BlockBreak | Tok::Block(_) => {}
}
}
let runs = apostrophe_runs(&units);
let mut budget = units
.len()
.saturating_mul(8)
.saturating_add(64)
.min(200_000);
let (nodes, _, _) = parse_runs(&units, &runs, 0, None, &mut budget);
nodes
}
fn apostrophe_runs(units: &[Unit]) -> Vec<usize> {
let mut runs = vec![0usize; units.len()];
for i in (0..units.len()).rev() {
if matches!(units.get(i), Some(Unit::Apostrophe)) {
let next = runs.get(i + 1).copied().unwrap_or(0);
if let Some(slot) = runs.get_mut(i) {
*slot = 1 + next;
}
}
}
runs
}
fn emphasis_width(strong: bool) -> usize {
if strong { 3 } else { 2 }
}
fn try_open(
units: &[Unit],
runs: &[usize],
i: usize,
strong: bool,
budget: &mut usize,
) -> Option<(Inline, usize)> {
if *budget == 0 {
return None;
}
*budget -= 1;
let width = emphasis_width(strong);
let (body, next, closed) = parse_runs(units, runs, i + width, Some(strong), budget);
if !closed || body.is_empty() {
return None;
}
let body = strip_outer_whitespace(body);
Some((
if strong {
Inline::Strong(body)
} else {
Inline::Emph(body)
},
next,
))
}
fn parse_runs(
units: &[Unit],
runs: &[usize],
start: usize,
closer: Option<bool>,
budget: &mut usize,
) -> (Vec<Inline>, usize, bool) {
let mut nodes: Vec<Inline> = Vec::new();
let mut pos = start;
while let Some(unit) = units.get(pos) {
match unit {
Unit::Node(inline) => {
nodes.push(inline.clone());
pos += 1;
}
Unit::Apostrophe => {
let run = runs.get(pos).copied().unwrap_or(0);
if run >= emphasis_width(true)
&& closer != Some(true)
&& let Some((span, next)) = try_open(units, runs, pos, true, budget)
{
nodes.push(span);
pos = next;
continue;
}
if let Some(strong) = closer
&& run >= emphasis_width(strong)
{
return (nodes, pos + emphasis_width(strong), true);
}
if run >= emphasis_width(false)
&& closer != Some(false)
&& let Some((span, next)) = try_open(units, runs, pos, false, budget)
{
nodes.push(span);
pos = next;
continue;
}
nodes.push(Inline::Str("'".into()));
pos += 1;
}
}
}
(nodes, pos, closer.is_none())
}
fn strip_outer_whitespace(mut inlines: Vec<Inline>) -> Vec<Inline> {
let lead = inlines
.iter()
.take_while(|x| matches!(x, Inline::Space | Inline::SoftBreak))
.count();
inlines.drain(0..lead);
while matches!(inlines.last(), Some(Inline::Space | Inline::SoftBreak)) {
inlines.pop();
}
inlines
}
enum SmartUnit {
Quote,
Ch(char),
Space(Inline),
Node(Inline),
}
fn apply_smart_quotes(inlines: Vec<Inline>) -> Vec<Inline> {
let recursed: Vec<Inline> = inlines.into_iter().map(smart_descend).collect();
let units = flatten_smart(recursed);
resolve_double_quotes(&units, 0, units.len())
}
fn smart_descend(inline: Inline) -> Inline {
match inline {
Inline::Emph(v) => Inline::Emph(apply_smart_quotes(v)),
Inline::Underline(v) => Inline::Underline(apply_smart_quotes(v)),
Inline::Strong(v) => Inline::Strong(apply_smart_quotes(v)),
Inline::Strikeout(v) => Inline::Strikeout(apply_smart_quotes(v)),
Inline::Superscript(v) => Inline::Superscript(apply_smart_quotes(v)),
Inline::Subscript(v) => Inline::Subscript(apply_smart_quotes(v)),
Inline::SmallCaps(v) => Inline::SmallCaps(apply_smart_quotes(v)),
Inline::Quoted(quote_type, v) => Inline::Quoted(quote_type, apply_smart_quotes(v)),
Inline::Span(attr, v) => Inline::Span(attr, apply_smart_quotes(v)),
Inline::Link(attr, v, target) => Inline::Link(attr, apply_smart_quotes(v), target),
Inline::Image(attr, v, target) => Inline::Image(attr, apply_smart_quotes(v), target),
other => other,
}
}
fn flatten_smart(inlines: Vec<Inline>) -> Vec<SmartUnit> {
let mut units: Vec<SmartUnit> = Vec::new();
for inline in inlines {
match inline {
Inline::Str(text) => {
for c in text.chars() {
if c == '"' {
units.push(SmartUnit::Quote);
} else {
units.push(SmartUnit::Ch(c));
}
}
}
space @ (Inline::Space | Inline::SoftBreak | Inline::LineBreak) => {
units.push(SmartUnit::Space(space));
}
other => units.push(SmartUnit::Node(other)),
}
}
units
}
fn resolve_double_quotes(units: &[SmartUnit], lo: usize, hi: usize) -> Vec<Inline> {
let mut out: Vec<Inline> = Vec::new();
let mut buf = String::new();
let mut i = lo;
while i < hi {
match units.get(i) {
Some(SmartUnit::Quote) => {
if smart_quote_opens(units, i, hi)
&& let Some(j) = next_smart_quote(units, i + 1, hi)
{
flush_smart_buf(&mut buf, &mut out);
out.push(Inline::Quoted(
QuoteType::DoubleQuote,
strip_outer_whitespace(resolve_double_quotes(units, i + 1, j)),
));
i = j + 1;
} else {
buf.push('"');
i += 1;
}
}
Some(SmartUnit::Ch(c)) => {
buf.push(*c);
i += 1;
}
Some(SmartUnit::Space(inline) | SmartUnit::Node(inline)) => {
flush_smart_buf(&mut buf, &mut out);
out.push(inline.clone());
i += 1;
}
None => break,
}
}
flush_smart_buf(&mut buf, &mut out);
out
}
fn flush_smart_buf(buf: &mut String, out: &mut Vec<Inline>) {
if !buf.is_empty() {
out.push(Inline::Str(std::mem::take(buf).into()));
}
}
fn smart_quote_opens(units: &[SmartUnit], i: usize, hi: usize) -> bool {
if i + 1 >= hi {
return false;
}
match units.get(i + 1) {
Some(SmartUnit::Ch(c)) => !c.is_whitespace(),
Some(SmartUnit::Quote | SmartUnit::Node(_)) => true,
Some(SmartUnit::Space(_)) | None => false,
}
}
fn next_smart_quote(units: &[SmartUnit], from: usize, hi: usize) -> Option<usize> {
(from..hi).find(|&j| matches!(units.get(j), Some(SmartUnit::Quote)))
}
fn coalesce(inlines: Vec<Inline>) -> Vec<Inline> {
let mut out: Vec<Inline> = Vec::new();
for inline in inlines {
let inline = match inline {
Inline::Emph(xs) => Inline::Emph(coalesce(xs)),
Inline::Strong(xs) => Inline::Strong(coalesce(xs)),
Inline::Strikeout(xs) => Inline::Strikeout(coalesce(xs)),
Inline::Superscript(xs) => Inline::Superscript(coalesce(xs)),
Inline::Subscript(xs) => Inline::Subscript(coalesce(xs)),
Inline::Underline(xs) => Inline::Underline(coalesce(xs)),
Inline::SmallCaps(xs) => Inline::SmallCaps(coalesce(xs)),
Inline::Span(attr, xs) => Inline::Span(attr, coalesce(xs)),
other => other,
};
match (out.last_mut(), &inline) {
(Some(Inline::Str(prev)), Inline::Str(next)) => prev.push_str(next),
(
Some(slot @ (Inline::Space | Inline::SoftBreak)),
Inline::Space | Inline::SoftBreak,
) => {
if matches!(inline, Inline::SoftBreak) {
*slot = Inline::SoftBreak;
}
}
_ => out.push(inline),
}
}
out
}
fn drop_east_asian_breaks(inlines: Vec<Inline>) -> Vec<Inline> {
let mut out: Vec<Inline> = Vec::with_capacity(inlines.len());
let mut iter = inlines.into_iter().peekable();
while let Some(inline) = iter.next() {
if matches!(inline, Inline::SoftBreak) {
let prev_wide = out.last().and_then(trailing_char).is_some_and(is_wide_char);
let next_wide = iter.peek().and_then(leading_char).is_some_and(is_wide_char);
if prev_wide && next_wide {
continue;
}
}
out.push(inline);
}
out
}
fn trailing_char(inline: &Inline) -> Option<char> {
match inline {
Inline::Str(s) | Inline::Code(_, s) | Inline::Math(_, s) | Inline::RawInline(_, s) => {
s.chars().next_back()
}
Inline::Emph(xs)
| Inline::Underline(xs)
| Inline::Strong(xs)
| Inline::Strikeout(xs)
| Inline::Superscript(xs)
| Inline::Subscript(xs)
| Inline::SmallCaps(xs)
| Inline::Quoted(_, xs)
| Inline::Span(_, xs)
| Inline::Link(_, xs, _)
| Inline::Cite(_, xs) => xs.iter().rev().find_map(trailing_char),
_ => None,
}
}
fn leading_char(inline: &Inline) -> Option<char> {
match inline {
Inline::Str(s) | Inline::Code(_, s) | Inline::Math(_, s) | Inline::RawInline(_, s) => {
s.chars().next()
}
Inline::Emph(xs)
| Inline::Underline(xs)
| Inline::Strong(xs)
| Inline::Strikeout(xs)
| Inline::Superscript(xs)
| Inline::Subscript(xs)
| Inline::SmallCaps(xs)
| Inline::Quoted(_, xs)
| Inline::Span(_, xs)
| Inline::Link(_, xs, _)
| Inline::Cite(_, xs) => xs.iter().find_map(leading_char),
_ => None,
}
}
fn is_wide_char(c: char) -> bool {
let cp = c as u32;
matches!(cp,
0x1100..=0x115F
| 0x2329 | 0x232A
| 0x2E80..=0x303E
| 0x3041..=0x33FF
| 0x3400..=0x4DBF
| 0x4E00..=0x9FFF
| 0xA000..=0xA4CF
| 0xA960..=0xA97F
| 0xAC00..=0xD7A3
| 0xF900..=0xFAFF
| 0xFE10..=0xFE19
| 0xFE30..=0xFE6F
| 0xFF00..=0xFF60
| 0xFFE0..=0xFFE6
| 0x1B000..=0x1B2FF
| 0x1F200..=0x1F2FF
| 0x1F300..=0x1F64F
| 0x1F900..=0x1F9FF
| 0x20000..=0x3FFFD
)
}
fn preformat_transform(inlines: Vec<Inline>) -> Vec<Inline> {
let mut out: Vec<Inline> = Vec::new();
let mut run = String::new();
for inline in inlines {
match inline {
Inline::Str(s) => run.push_str(&s.replace(' ', "\u{a0}")),
Inline::Space | Inline::SoftBreak => run.push('\u{a0}'),
other => {
if !run.is_empty() {
out.push(Inline::Code(
Box::default(),
std::mem::take(&mut run).into(),
));
}
out.push(preformat_descend(other));
}
}
}
if !run.is_empty() {
out.push(Inline::Code(Box::default(), run.into()));
}
out
}
fn preformat_descend(inline: Inline) -> Inline {
match inline {
Inline::Emph(xs) => Inline::Emph(preformat_transform(xs)),
Inline::Strong(xs) => Inline::Strong(preformat_transform(xs)),
Inline::Strikeout(xs) => Inline::Strikeout(preformat_transform(xs)),
Inline::Superscript(xs) => Inline::Superscript(preformat_transform(xs)),
Inline::Subscript(xs) => Inline::Subscript(preformat_transform(xs)),
Inline::Underline(xs) => Inline::Underline(preformat_transform(xs)),
Inline::SmallCaps(xs) => Inline::SmallCaps(preformat_transform(xs)),
Inline::Span(attr, xs) => Inline::Span(attr, preformat_transform(xs)),
Inline::Link(attr, xs, target) => Inline::Link(attr, preformat_transform(xs), target),
other => other,
}
}
fn plain_inlines(text: &str) -> Vec<Inline> {
let chars: Vec<char> = text.chars().collect();
let n = chars.len();
let mut out: Vec<Inline> = Vec::new();
let mut word = String::new();
let mut i = 0;
while i < n {
let Some(c) = at(&chars, i) else { break };
if c.is_whitespace() {
if !word.is_empty() {
out.push(Inline::Str(std::mem::take(&mut word).into()));
}
let (token, next) = whitespace_token(&chars, i);
out.push(token);
i = next;
} else if c == '&' {
if let Some((decoded, next)) = entities::read_reference(&chars, i, chars.len(), true) {
word.push_str(&decoded);
i = next;
} else {
word.push('&');
i += 1;
}
} else {
word.push(c);
i += 1;
}
}
if !word.is_empty() {
out.push(Inline::Str(word.into()));
}
out
}
fn decode_entities(text: &str) -> String {
let chars: Vec<char> = text.chars().collect();
let n = chars.len();
let mut out = String::new();
let mut i = 0;
while i < n {
if at(&chars, i) == Some('&')
&& let Some((decoded, next)) = entities::read_reference(&chars, i, chars.len(), true)
{
out.push_str(&decoded);
i = next;
continue;
}
if let Some(c) = at(&chars, i) {
out.push(c);
}
i += 1;
}
out
}
fn is_scheme(name: &str) -> bool {
let lower = name.to_ascii_lowercase();
crate::url_schemes::is_scheme(&lower) || lower == "doi" || lower == "javascript"
}
fn is_url(text: &str) -> bool {
match text.split_once(':') {
Some((scheme, _)) => {
!scheme.is_empty()
&& scheme
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
&& is_scheme(scheme)
}
None => false,
}
}
fn url_scheme_len(chars: &[char], i: usize) -> Option<usize> {
let mut j = i;
let mut name = String::new();
while let Some(c) = at(chars, j) {
if c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.') {
name.push(c);
j += 1;
} else {
break;
}
}
if name.is_empty() || at(chars, j) != Some(':') || !is_scheme(&name) {
return None;
}
Some(j - i + 1)
}
fn bare_url(chars: &[char], i: usize) -> Option<(Inline, usize)> {
let scheme_len = url_scheme_len(chars, i)?;
let mut j = i + scheme_len;
while let Some(c) = at(chars, j) {
if c.is_whitespace() || matches!(c, '<' | '>') {
break;
}
if c == '\'' && at(chars, j + 1) == Some('\'') {
break;
}
j += 1;
}
if j <= i + scheme_len {
return None;
}
let mut display = collect_range(chars, i, j);
trim_url_trailing(&mut display);
if display.is_empty() {
return None;
}
let consumed = display.chars().count();
let target = encode_url_target(&display);
Some((
Inline::Link(
Box::default(),
vec![Inline::Str(display.into())],
Box::new(Target {
url: target.into(),
title: carta_ast::Text::default(),
}),
),
i + consumed,
))
}
fn trim_url_trailing(url: &mut String) {
while let Some(last) = url.chars().last() {
let always = matches!(
last,
'.' | ',' | ';' | ':' | '!' | '?' | '"' | '*' | '~' | '\'' | '|'
);
let unbalanced = match last {
')' => url.matches(')').count() > url.matches('(').count(),
']' => url.matches(']').count() > url.matches('[').count(),
'}' => url.matches('}').count() > url.matches('{').count(),
_ => false,
};
if always || unbalanced {
url.pop();
} else {
break;
}
}
}
fn encode_url_target(url: &str) -> String {
let mut out = String::with_capacity(url.len());
for ch in url.chars() {
match ch {
' ' => out.push_str("%20"),
'"' => out.push_str("%22"),
'`' => out.push_str("%60"),
'^' => out.push_str("%5E"),
'[' => out.push_str("%5B"),
']' => out.push_str("%5D"),
'{' => out.push_str("%7B"),
'}' => out.push_str("%7D"),
'|' => out.push_str("%7C"),
other => out.push(other),
}
}
out
}
fn wikilink_url(target: &str) -> String {
let mut out = String::new();
let mut pending = false;
for ch in target.chars() {
if ch.is_whitespace() {
pending = true;
} else {
if pending {
out.push('_');
pending = false;
}
out.push(ch);
}
}
out
}
fn title_text(inlines: &[Inline]) -> String {
let mut out = String::new();
push_title_text(inlines, &mut out);
out
}
fn push_title_text(inlines: &[Inline], out: &mut String) {
for inline in inlines {
match inline {
Inline::Str(text) | Inline::Code(_, text) | Inline::Math(_, text) => out.push_str(text),
Inline::Space | Inline::SoftBreak | Inline::LineBreak => out.push(' '),
Inline::Quoted(QuoteType::SingleQuote, xs) => {
out.push('\u{2018}');
push_title_text(xs, out);
out.push('\u{2019}');
}
Inline::Quoted(QuoteType::DoubleQuote, xs) => {
out.push('\u{201c}');
push_title_text(xs, out);
out.push('\u{201d}');
}
Inline::Emph(xs)
| Inline::Underline(xs)
| Inline::Strong(xs)
| Inline::Strikeout(xs)
| Inline::Superscript(xs)
| Inline::Subscript(xs)
| Inline::SmallCaps(xs)
| Inline::Cite(_, xs)
| Inline::Link(_, xs, _)
| Inline::Image(_, xs, _)
| Inline::Span(_, xs) => push_title_text(xs, out),
Inline::RawInline(..) | Inline::Note(_) => {}
}
}
}
fn namespace_of(target: &str) -> Option<String> {
if target.starts_with(':') {
return None;
}
let (before, _) = target.split_once(':')?;
Some(before.trim().to_lowercase())
}
fn strip_namespace(target: &str) -> &str {
match target.split_once(':') {
Some((_, rest)) => rest.trim(),
None => target,
}
}
fn image_size(param: &str) -> Option<(String, Option<String>)> {
let digits = param.strip_suffix("px")?;
match digits.split_once('x') {
Some((width, height)) => {
let valid = width.chars().all(|c| c.is_ascii_digit())
&& !height.is_empty()
&& height.chars().all(|c| c.is_ascii_digit());
valid.then(|| (width.to_string(), Some(height.to_string())))
}
None => (!digits.is_empty() && digits.chars().all(|c| c.is_ascii_digit()))
.then(|| (digits.to_string(), None)),
}
}
fn image_param_declines(param: &str) -> bool {
match param.split_once('=') {
Some((key, _)) => {
let key = key.trim().to_ascii_lowercase();
key == "thumbtime" || key == "upright"
}
None => param.trim().eq_ignore_ascii_case("thumbtime"),
}
}
fn is_recognized_image_attr(param: &str) -> bool {
match param.split_once('=') {
Some((key, _)) => matches!(
key.trim().to_ascii_lowercase().as_str(),
"alt" | "link" | "class" | "page"
),
None => false,
}
}
fn is_image_keyword(param: &str) -> bool {
matches!(
param.to_ascii_lowercase().as_str(),
"thumb"
| "thumbnail"
| "frame"
| "framed"
| "frameless"
| "border"
| "left"
| "right"
| "center"
| "centre"
| "none"
| "upright"
| "baseline"
| "sub"
| "super"
| "top"
| "text-top"
| "middle"
| "bottom"
| "text-bottom"
)
}
fn para_or_figure(inlines: Vec<Inline>) -> Block {
match lone_image_figure(&inlines) {
Some(figure) => figure,
None => Block::Para(inlines),
}
}
fn plain_or_figure(inlines: Vec<Inline>) -> Block {
match lone_image_figure(&inlines) {
Some(figure) => figure,
None => Block::Plain(inlines),
}
}
fn lone_image_figure(inlines: &[Inline]) -> Option<Block> {
let mut significant = inlines.iter().filter(|inline| {
!matches!(
inline,
Inline::Space | Inline::SoftBreak | Inline::LineBreak
)
});
let Inline::Image(attr, alt, target) = significant.next()? else {
return None;
};
if significant.next().is_some() {
return None;
}
let caption = Caption {
short: None,
long: vec![Block::Plain(alt.clone())],
};
let image = Inline::Image(attr.clone(), Vec::new(), target.clone());
Some(Block::Figure(
Box::default(),
Box::new(caption),
vec![Block::Plain(vec![image])],
))
}
fn emoji_to_aliases(text: &str) -> String {
let mut out = String::new();
let mut rest = text;
while !rest.is_empty() {
if let Some((alias, len)) = emoji::alias_at(rest) {
out.push_str(alias);
rest = rest.get(len..).unwrap_or("");
} else if let Some(ch) = rest.chars().next() {
out.push(ch);
rest = rest.get(ch.len_utf8()..).unwrap_or("");
} else {
break;
}
}
out
}
fn mediawiki_slug(text: &str) -> String {
let mut out = String::new();
let mut in_ws = false;
for ch in text.chars() {
if ch.is_whitespace() {
if !in_ws {
out.push('_');
in_ws = true;
}
} else if ch == '-' {
out.push('_');
in_ws = false;
} else if ch.is_alphanumeric() || ch == '_' || ch == '.' {
out.extend(ch.to_lowercase());
in_ws = false;
}
}
out.chars().skip_while(|c| !c.is_alphabetic()).collect()
}
fn transliterate_ascii(text: &str) -> String {
let mut out = String::with_capacity(text.len());
for ch in text.chars() {
if ch.is_ascii() {
out.push(ch);
} else if let Ok(index) = ASCII_FOLD.binary_search_by(|&(cp, _)| cp.cmp(&(ch as u32)))
&& let Some(&(_, byte)) = ASCII_FOLD.get(index)
{
out.push(byte as char);
}
}
out
}
const ASCII_FOLD: &[(u32, u8)] = &[
(0x00C0, b'a'),
(0x00C1, b'a'),
(0x00C2, b'a'),
(0x00C3, b'a'),
(0x00C4, b'a'),
(0x00C5, b'a'),
(0x00C7, b'c'),
(0x00C8, b'e'),
(0x00C9, b'e'),
(0x00CA, b'e'),
(0x00CB, b'e'),
(0x00CC, b'i'),
(0x00CD, b'i'),
(0x00CE, b'i'),
(0x00CF, b'i'),
(0x00D1, b'n'),
(0x00D2, b'o'),
(0x00D3, b'o'),
(0x00D4, b'o'),
(0x00D5, b'o'),
(0x00D6, b'o'),
(0x00D9, b'u'),
(0x00DA, b'u'),
(0x00DB, b'u'),
(0x00DC, b'u'),
(0x00DD, b'y'),
(0x00E0, b'a'),
(0x00E1, b'a'),
(0x00E2, b'a'),
(0x00E3, b'a'),
(0x00E4, b'a'),
(0x00E5, b'a'),
(0x00E7, b'c'),
(0x00E8, b'e'),
(0x00E9, b'e'),
(0x00EA, b'e'),
(0x00EB, b'e'),
(0x00EC, b'i'),
(0x00ED, b'i'),
(0x00EE, b'i'),
(0x00EF, b'i'),
(0x00F1, b'n'),
(0x00F2, b'o'),
(0x00F3, b'o'),
(0x00F4, b'o'),
(0x00F5, b'o'),
(0x00F6, b'o'),
(0x00F9, b'u'),
(0x00FA, b'u'),
(0x00FB, b'u'),
(0x00FC, b'u'),
(0x00FD, b'y'),
(0x00FF, b'y'),
(0x0100, b'a'),
(0x0101, b'a'),
(0x0102, b'a'),
(0x0103, b'a'),
(0x0104, b'a'),
(0x0105, b'a'),
(0x0106, b'c'),
(0x0107, b'c'),
(0x0108, b'c'),
(0x0109, b'c'),
(0x010A, b'c'),
(0x010B, b'c'),
(0x010C, b'c'),
(0x010D, b'c'),
(0x010E, b'd'),
(0x010F, b'd'),
(0x0112, b'e'),
(0x0113, b'e'),
(0x0114, b'e'),
(0x0115, b'e'),
(0x0116, b'e'),
(0x0117, b'e'),
(0x0118, b'e'),
(0x0119, b'e'),
(0x011A, b'e'),
(0x011B, b'e'),
(0x011C, b'g'),
(0x011D, b'g'),
(0x011E, b'g'),
(0x011F, b'g'),
(0x0120, b'g'),
(0x0121, b'g'),
(0x0122, b'g'),
(0x0123, b'g'),
(0x0124, b'h'),
(0x0125, b'h'),
(0x0128, b'i'),
(0x0129, b'i'),
(0x012A, b'i'),
(0x012B, b'i'),
(0x012C, b'i'),
(0x012D, b'i'),
(0x012E, b'i'),
(0x012F, b'i'),
(0x0130, b'i'),
(0x0134, b'j'),
(0x0135, b'j'),
(0x0136, b'k'),
(0x0137, b'k'),
(0x0139, b'l'),
(0x013A, b'l'),
(0x013B, b'l'),
(0x013C, b'l'),
(0x013D, b'l'),
(0x013E, b'l'),
(0x0143, b'n'),
(0x0144, b'n'),
(0x0145, b'n'),
(0x0146, b'n'),
(0x0147, b'n'),
(0x0148, b'n'),
(0x014C, b'o'),
(0x014D, b'o'),
(0x014E, b'o'),
(0x014F, b'o'),
(0x0150, b'o'),
(0x0151, b'o'),
(0x0154, b'r'),
(0x0155, b'r'),
(0x0156, b'r'),
(0x0157, b'r'),
(0x0158, b'r'),
(0x0159, b'r'),
(0x015A, b's'),
(0x015B, b's'),
(0x015C, b's'),
(0x015D, b's'),
(0x015E, b's'),
(0x015F, b's'),
(0x0160, b's'),
(0x0161, b's'),
(0x0162, b't'),
(0x0163, b't'),
(0x0164, b't'),
(0x0165, b't'),
(0x0168, b'u'),
(0x0169, b'u'),
(0x016A, b'u'),
(0x016B, b'u'),
(0x016C, b'u'),
(0x016D, b'u'),
(0x016E, b'u'),
(0x016F, b'u'),
(0x0170, b'u'),
(0x0171, b'u'),
(0x0172, b'u'),
(0x0173, b'u'),
(0x0174, b'w'),
(0x0175, b'w'),
(0x0176, b'y'),
(0x0177, b'y'),
(0x0178, b'y'),
(0x0179, b'z'),
(0x017A, b'z'),
(0x017B, b'z'),
(0x017C, b'z'),
(0x017D, b'z'),
(0x017E, b'z'),
(0x01A0, b'o'),
(0x01A1, b'o'),
(0x01AF, b'u'),
(0x01B0, b'u'),
(0x01CD, b'a'),
(0x01CE, b'a'),
(0x01CF, b'i'),
(0x01D0, b'i'),
(0x01D1, b'o'),
(0x01D2, b'o'),
(0x01D3, b'u'),
(0x01D4, b'u'),
(0x01D5, b'u'),
(0x01D6, b'u'),
(0x01D7, b'u'),
(0x01D8, b'u'),
(0x01D9, b'u'),
(0x01DA, b'u'),
(0x01DB, b'u'),
(0x01DC, b'u'),
(0x01DE, b'a'),
(0x01DF, b'a'),
(0x01E0, b'a'),
(0x01E1, b'a'),
(0x01E6, b'g'),
(0x01E7, b'g'),
(0x01E8, b'k'),
(0x01E9, b'k'),
(0x01EA, b'o'),
(0x01EB, b'o'),
(0x01EC, b'o'),
(0x01ED, b'o'),
(0x01F0, b'j'),
(0x01F4, b'g'),
(0x01F5, b'g'),
(0x01F8, b'n'),
(0x01F9, b'n'),
(0x01FA, b'a'),
(0x01FB, b'a'),
(0x0200, b'a'),
(0x0201, b'a'),
(0x0202, b'a'),
(0x0203, b'a'),
(0x0204, b'e'),
(0x0205, b'e'),
(0x0206, b'e'),
(0x0207, b'e'),
(0x0208, b'i'),
(0x0209, b'i'),
(0x020A, b'i'),
(0x020B, b'i'),
(0x020C, b'o'),
(0x020D, b'o'),
(0x020E, b'o'),
(0x020F, b'o'),
(0x0210, b'r'),
(0x0211, b'r'),
(0x0212, b'r'),
(0x0213, b'r'),
(0x0214, b'u'),
(0x0215, b'u'),
(0x0216, b'u'),
(0x0217, b'u'),
(0x0218, b's'),
(0x0219, b's'),
(0x021A, b't'),
(0x021B, b't'),
(0x021E, b'h'),
(0x021F, b'h'),
(0x0226, b'a'),
(0x0227, b'a'),
(0x0228, b'e'),
(0x0229, b'e'),
(0x022A, b'o'),
(0x022B, b'o'),
(0x022C, b'o'),
(0x022D, b'o'),
(0x022E, b'o'),
(0x022F, b'o'),
(0x0230, b'o'),
(0x0231, b'o'),
(0x0232, b'y'),
(0x0233, b'y'),
(0x1E00, b'a'),
(0x1E01, b'a'),
(0x1E02, b'b'),
(0x1E03, b'b'),
(0x1E04, b'b'),
(0x1E05, b'b'),
(0x1E06, b'b'),
(0x1E07, b'b'),
(0x1E08, b'c'),
(0x1E09, b'c'),
(0x1E0A, b'd'),
(0x1E0B, b'd'),
(0x1E0C, b'd'),
(0x1E0D, b'd'),
(0x1E0E, b'd'),
(0x1E0F, b'd'),
(0x1E10, b'd'),
(0x1E11, b'd'),
(0x1E12, b'd'),
(0x1E13, b'd'),
(0x1E14, b'e'),
(0x1E15, b'e'),
(0x1E16, b'e'),
(0x1E17, b'e'),
(0x1E18, b'e'),
(0x1E19, b'e'),
(0x1E1A, b'e'),
(0x1E1B, b'e'),
(0x1E1C, b'e'),
(0x1E1D, b'e'),
(0x1E1E, b'f'),
(0x1E1F, b'f'),
(0x1E20, b'g'),
(0x1E21, b'g'),
(0x1E22, b'h'),
(0x1E23, b'h'),
(0x1E24, b'h'),
(0x1E25, b'h'),
(0x1E26, b'h'),
(0x1E27, b'h'),
(0x1E28, b'h'),
(0x1E29, b'h'),
(0x1E2A, b'h'),
(0x1E2B, b'h'),
(0x1E2C, b'i'),
(0x1E2D, b'i'),
(0x1E2E, b'i'),
(0x1E2F, b'i'),
(0x1E30, b'k'),
(0x1E31, b'k'),
(0x1E32, b'k'),
(0x1E33, b'k'),
(0x1E34, b'k'),
(0x1E35, b'k'),
(0x1E36, b'l'),
(0x1E37, b'l'),
(0x1E38, b'l'),
(0x1E39, b'l'),
(0x1E3A, b'l'),
(0x1E3B, b'l'),
(0x1E3C, b'l'),
(0x1E3D, b'l'),
(0x1E3E, b'm'),
(0x1E3F, b'm'),
(0x1E40, b'm'),
(0x1E41, b'm'),
(0x1E42, b'm'),
(0x1E43, b'm'),
(0x1E44, b'n'),
(0x1E45, b'n'),
(0x1E46, b'n'),
(0x1E47, b'n'),
(0x1E48, b'n'),
(0x1E49, b'n'),
(0x1E4A, b'n'),
(0x1E4B, b'n'),
(0x1E4C, b'o'),
(0x1E4D, b'o'),
(0x1E4E, b'o'),
(0x1E4F, b'o'),
(0x1E50, b'o'),
(0x1E51, b'o'),
(0x1E52, b'o'),
(0x1E53, b'o'),
(0x1E54, b'p'),
(0x1E55, b'p'),
(0x1E56, b'p'),
(0x1E57, b'p'),
(0x1E58, b'r'),
(0x1E59, b'r'),
(0x1E5A, b'r'),
(0x1E5B, b'r'),
(0x1E5C, b'r'),
(0x1E5D, b'r'),
(0x1E5E, b'r'),
(0x1E5F, b'r'),
(0x1E60, b's'),
(0x1E61, b's'),
(0x1E62, b's'),
(0x1E63, b's'),
(0x1E64, b's'),
(0x1E65, b's'),
(0x1E66, b's'),
(0x1E67, b's'),
(0x1E68, b's'),
(0x1E69, b's'),
(0x1E6A, b't'),
(0x1E6B, b't'),
(0x1E6C, b't'),
(0x1E6D, b't'),
(0x1E6E, b't'),
(0x1E6F, b't'),
(0x1E70, b't'),
(0x1E71, b't'),
(0x1E72, b'u'),
(0x1E73, b'u'),
(0x1E74, b'u'),
(0x1E75, b'u'),
(0x1E76, b'u'),
(0x1E77, b'u'),
(0x1E78, b'u'),
(0x1E79, b'u'),
(0x1E7A, b'u'),
(0x1E7B, b'u'),
(0x1E7C, b'v'),
(0x1E7D, b'v'),
(0x1E7E, b'v'),
(0x1E7F, b'v'),
(0x1E80, b'w'),
(0x1E81, b'w'),
(0x1E82, b'w'),
(0x1E83, b'w'),
(0x1E84, b'w'),
(0x1E85, b'w'),
(0x1E86, b'w'),
(0x1E87, b'w'),
(0x1E88, b'w'),
(0x1E89, b'w'),
(0x1E8A, b'x'),
(0x1E8B, b'x'),
(0x1E8C, b'x'),
(0x1E8D, b'x'),
(0x1E8E, b'y'),
(0x1E8F, b'y'),
(0x1E90, b'z'),
(0x1E91, b'z'),
(0x1E92, b'z'),
(0x1E93, b'z'),
(0x1E94, b'z'),
(0x1E95, b'z'),
(0x1E96, b'h'),
(0x1E97, b't'),
(0x1E98, b'w'),
(0x1E99, b'y'),
(0x1EA0, b'a'),
(0x1EA1, b'a'),
(0x1EA2, b'a'),
(0x1EA3, b'a'),
(0x1EA4, b'a'),
(0x1EA5, b'a'),
(0x1EA6, b'a'),
(0x1EA7, b'a'),
(0x1EA8, b'a'),
(0x1EA9, b'a'),
(0x1EAA, b'a'),
(0x1EAB, b'a'),
(0x1EAC, b'a'),
(0x1EAD, b'a'),
(0x1EAE, b'a'),
(0x1EAF, b'a'),
(0x1EB0, b'a'),
(0x1EB1, b'a'),
(0x1EB2, b'a'),
(0x1EB3, b'a'),
(0x1EB4, b'a'),
(0x1EB5, b'a'),
(0x1EB6, b'a'),
(0x1EB7, b'a'),
(0x1EB8, b'e'),
(0x1EB9, b'e'),
(0x1EBA, b'e'),
(0x1EBB, b'e'),
(0x1EBC, b'e'),
(0x1EBD, b'e'),
(0x1EBE, b'e'),
(0x1EBF, b'e'),
(0x1EC0, b'e'),
(0x1EC1, b'e'),
(0x1EC2, b'e'),
(0x1EC3, b'e'),
(0x1EC4, b'e'),
(0x1EC5, b'e'),
(0x1EC6, b'e'),
(0x1EC7, b'e'),
(0x1EC8, b'i'),
(0x1EC9, b'i'),
(0x1ECA, b'i'),
(0x1ECB, b'i'),
(0x1ECC, b'o'),
(0x1ECD, b'o'),
(0x1ECE, b'o'),
(0x1ECF, b'o'),
(0x1ED0, b'o'),
(0x1ED1, b'o'),
(0x1ED2, b'o'),
(0x1ED3, b'o'),
(0x1ED4, b'o'),
(0x1ED5, b'o'),
(0x1ED6, b'o'),
(0x1ED7, b'o'),
(0x1ED8, b'o'),
(0x1ED9, b'o'),
(0x1EDA, b'o'),
(0x1EDB, b'o'),
(0x1EDC, b'o'),
(0x1EDD, b'o'),
(0x1EDE, b'o'),
(0x1EDF, b'o'),
(0x1EE0, b'o'),
(0x1EE1, b'o'),
(0x1EE2, b'o'),
(0x1EE3, b'o'),
(0x1EE4, b'u'),
(0x1EE5, b'u'),
(0x1EE6, b'u'),
(0x1EE7, b'u'),
(0x1EE8, b'u'),
(0x1EE9, b'u'),
(0x1EEA, b'u'),
(0x1EEB, b'u'),
(0x1EEC, b'u'),
(0x1EED, b'u'),
(0x1EEE, b'u'),
(0x1EEF, b'u'),
(0x1EF0, b'u'),
(0x1EF1, b'u'),
(0x1EF2, b'y'),
(0x1EF3, b'y'),
(0x1EF4, b'y'),
(0x1EF5, b'y'),
(0x1EF6, b'y'),
(0x1EF7, b'y'),
(0x1EF8, b'y'),
(0x1EF9, b'y'),
(0x212A, b'k'),
(0x212B, b'a'),
];
fn open_tag(chars: &[char], start: usize) -> Option<(String, String, bool, usize)> {
let mut cursor = start + 1;
let mut name = String::new();
while let Some(ch) = at(chars, cursor) {
if ch.is_ascii_alphanumeric() {
name.push(ch.to_ascii_lowercase());
cursor += 1;
} else {
break;
}
}
if name.is_empty() {
return None;
}
let mut quote: Option<char> = None;
let len = chars.len();
while cursor < len {
let Some(ch) = at(chars, cursor) else { break };
match quote {
Some(open_quote) => {
if ch == open_quote {
quote = None;
}
cursor += 1;
}
None => {
if ch == '"' || ch == '\'' {
quote = Some(ch);
cursor += 1;
} else if ch == '>' {
break;
} else {
cursor += 1;
}
}
}
}
if at(chars, cursor) != Some('>') {
return None;
}
let self_closing = cursor > 0 && at(chars, cursor - 1) == Some('/');
let raw = collect_range(chars, start, cursor + 1);
Some((name, raw, self_closing, cursor + 1))
}
fn close_tag(chars: &[char], start: usize, name: &str) -> Option<(usize, usize)> {
let mut depth = 0i32;
let mut j = start;
let n = chars.len();
while j < n {
if at(chars, j) == Some('<') {
if at(chars, j + 1) == Some('/') {
if tag_name_matches(chars, j + 2, name) {
if depth == 0 {
let gt = find_char(chars, j, '>')?;
return Some((j, gt + 1));
}
depth -= 1;
}
} else if tag_name_matches(chars, j + 1, name) {
depth += 1;
}
}
j += 1;
}
None
}
fn enclosed(chars: &[char], start: usize, name: &str) -> (String, usize) {
match close_tag(chars, start, name) {
Some((inner_end, after)) => (collect_range(chars, start, inner_end), after),
None => (collect_range(chars, start, chars.len()), chars.len()),
}
}
fn tag_name_matches(chars: &[char], pos: usize, name: &str) -> bool {
let mut count = 0;
for (k, nc) in name.chars().enumerate() {
match at(chars, pos + k) {
Some(c) if c.eq_ignore_ascii_case(&nc) => count += 1,
_ => return false,
}
}
match at(chars, pos + count) {
Some(c) => c.is_whitespace() || c == '>' || c == '/',
None => false,
}
}
fn starts_block_tag(chars: &[char], pos: usize) -> bool {
if at(chars, pos) != Some('<') {
return false;
}
["pre", "source", "syntaxhighlight", "blockquote", "ul", "ol"]
.iter()
.any(|name| tag_name_matches(chars, pos + 1, name))
}
fn open_ref_depth(chars: &[char], start: usize, end: usize) -> i32 {
let mut depth = 0i32;
let mut i = start;
while i < end {
if at(chars, i) == Some('<') {
if let Some(after) = verbatim_region_end(chars, i) {
i = after;
continue;
}
if at(chars, i + 1) == Some('/') {
if tag_name_matches(chars, i + 2, "ref") {
depth = (depth - 1).max(0);
}
} else if tag_name_matches(chars, i + 1, "ref")
&& let Some((_, _, self_closing, after)) = open_tag(chars, i)
{
if !self_closing {
depth += 1;
}
i = after;
continue;
}
}
i += 1;
}
depth
}
fn open_ref_block_bodied(chars: &[char], start: usize, end: usize) -> bool {
let mut stack: Vec<bool> = Vec::new();
let mut i = start;
while i < end {
if at(chars, i) == Some('<') {
if let Some(after) = verbatim_region_end(chars, i) {
i = after;
continue;
}
if at(chars, i + 1) == Some('/') {
if tag_name_matches(chars, i + 2, "ref") {
stack.pop();
}
} else if tag_name_matches(chars, i + 1, "ref")
&& let Some((_, _, self_closing, after)) = open_tag(chars, i)
{
if !self_closing {
let mut j = after;
while matches!(at(chars, j), Some(' ' | '\t')) {
j += 1;
}
stack.push(matches!(at(chars, j), None | Some('\n')));
}
i = after;
continue;
}
}
i += 1;
}
stack.last().copied().unwrap_or(false)
}
fn html_tag_role(name: &str) -> Option<HtmlTagRole> {
const INLINE: &[&str] = &[
"abbr", "b", "bdi", "bdo", "big", "cite", "data", "dfn", "em", "font", "i", "ins", "q",
"rb", "rt", "rtc", "ruby", "s", "small", "span", "strong", "u", "wbr",
];
const BLOCK: &[&str] = &[
"caption",
"center",
"col",
"colgroup",
"dd",
"div",
"dl",
"dt",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"hr",
"li",
"ol",
"references",
"rp",
"table",
"td",
"th",
"time",
"tr",
"ul",
];
const PARAGRAPH: &[&str] = &["gallery", "p"];
if INLINE.contains(&name) {
Some(HtmlTagRole::Inline)
} else if BLOCK.contains(&name) {
Some(HtmlTagRole::Block)
} else if PARAGRAPH.contains(&name) {
Some(HtmlTagRole::Break)
} else {
None
}
}
fn close_tag_parse(chars: &[char], i: usize) -> Option<(String, String, usize)> {
if at(chars, i) != Some('<') || at(chars, i + 1) != Some('/') {
return None;
}
let mut cursor = i + 2;
let mut name = String::new();
while let Some(ch) = at(chars, cursor) {
if ch.is_ascii_alphanumeric() {
name.push(ch.to_ascii_lowercase());
cursor += 1;
} else {
break;
}
}
if name.is_empty() {
return None;
}
let gt = find_char(chars, cursor, '>')?;
Some((name, collect_range(chars, i, gt + 1), gt + 1))
}
fn html_li_content_bounds(chars: &[char], start: usize) -> (usize, usize) {
let n = chars.len();
let mut list_depth = 0i32;
let mut j = start;
while j < n {
if at(chars, j) == Some('<') {
if at(chars, j + 1) == Some('/') {
if tag_name_matches(chars, j + 2, "ul") || tag_name_matches(chars, j + 2, "ol") {
if list_depth == 0 {
return (j, j);
}
list_depth -= 1;
if let Some((_, _, after)) = close_tag_parse(chars, j) {
j = after;
continue;
}
} else if list_depth == 0
&& tag_name_matches(chars, j + 2, "li")
&& let Some((_, _, after)) = close_tag_parse(chars, j)
{
return (j, after);
}
} else if tag_name_matches(chars, j + 1, "ul") || tag_name_matches(chars, j + 1, "ol") {
if let Some((_, _, self_closing, after)) = open_tag(chars, j) {
if !self_closing {
list_depth += 1;
}
j = after;
continue;
}
} else if list_depth == 0 && tag_name_matches(chars, j + 1, "li") {
return (j, j);
}
}
j += 1;
}
(n, n)
}
fn block_tag_token(chars: &[char], i: usize) -> Option<(Tok, usize)> {
let (name, raw, after) = if at(chars, i + 1) == Some('/') {
close_tag_parse(chars, i)?
} else {
let (name, raw, _self_closing, after) = open_tag(chars, i)?;
(name, raw, after)
};
match html_tag_role(&name)? {
HtmlTagRole::Block => Some((Tok::BlockRaw(raw), after)),
HtmlTagRole::Break => Some((Tok::BlockBreak, after)),
HtmlTagRole::Inline => None,
}
}
fn list_run_uniform(chars: &[char], pos: usize) -> bool {
let first = at(chars, pos);
let le = line_end(chars, pos);
let mut p = pos;
while p < le && at(chars, p).is_some_and(is_list_marker) {
if at(chars, p) != first {
return false;
}
p += 1;
}
true
}
fn flush_para_segment(
segment: &mut Vec<Tok>,
blocks: &mut Vec<Block>,
smart: bool,
east_asian: bool,
) {
if segment.is_empty() {
return;
}
let toks = std::mem::take(segment);
let mut inlines = coalesce(strip_outer_whitespace(resolve_emphasis(toks)));
if east_asian {
inlines = drop_east_asian_breaks(inlines);
}
if smart {
inlines = apply_smart_quotes(inlines);
}
if !inlines.is_empty() {
blocks.push(para_or_figure(inlines));
}
}
fn tag_attribute(raw: &str, key: &str) -> Option<String> {
let chars: Vec<char> = raw.chars().collect();
let n = chars.len();
let mut i = 0;
while i < n {
match at(&chars, i) {
Some(c) if c.is_ascii_alphabetic() => {
let start = i;
while let Some(c) = at(&chars, i) {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
i += 1;
} else {
break;
}
}
let name = collect_range(&chars, start, i).to_lowercase();
while at(&chars, i).is_some_and(char::is_whitespace) {
i += 1;
}
if at(&chars, i) == Some('=') {
i += 1;
while at(&chars, i).is_some_and(char::is_whitespace) {
i += 1;
}
let value = if let Some(q @ ('"' | '\'')) = at(&chars, i) {
i += 1;
let vs = i;
while at(&chars, i).is_some_and(|c| c != q) {
i += 1;
}
let v = collect_range(&chars, vs, i);
i += 1;
v
} else {
let vs = i;
while at(&chars, i)
.is_some_and(|c| !c.is_whitespace() && c != '>' && c != '/')
{
i += 1;
}
collect_range(&chars, vs, i)
};
if name == key {
return Some(value);
}
}
}
_ => i += 1,
}
}
None
}
#[derive(Default)]
struct HeaderScan {
region_end: BTreeMap<usize, usize>,
is_header: BTreeMap<usize, bool>,
}
fn line_starts_block_scan(chars: &[char], ls: usize, scan: &mut HeaderScan) -> bool {
match at(chars, ls) {
Some('*' | '#' | ':' | ';' | ' ') => true,
Some('=') => is_header_line(chars, ls, scan),
Some('-') => is_hr_line(chars, ls),
Some('{') => matches!(at(chars, ls + 1), Some('{' | '|')),
Some('<') => starts_block_tag(chars, ls),
_ => false,
}
}
fn is_header_line_within(chars: &[char], pos: usize) -> bool {
is_header_line(chars, pos, &mut HeaderScan::default())
}
fn is_header_line(chars: &[char], pos: usize, scan: &mut HeaderScan) -> bool {
if let Some(&cached) = scan.is_header.get(&pos) {
return cached;
}
let le = line_end(chars, pos);
let mut m = 0;
while pos + m < le && at(chars, pos + m) == Some('=') {
m += 1;
}
let result = if m == 0 || m > 6 {
false
} else {
let region_end = header_region_end_scan(chars, pos, scan);
header_closer(chars, pos + m, region_end, m).is_some()
};
scan.is_header.insert(pos, result);
result
}
fn header_region_end_scan(chars: &[char], pos: usize, scan: &mut HeaderScan) -> usize {
if let Some(&cached) = scan.region_end.get(&pos) {
return cached;
}
let n = chars.len();
let mut starts = Vec::new();
let mut cur = pos;
loop {
starts.push(cur);
let le = line_end(chars, cur);
if le >= n {
break;
}
let next = le + 1;
if next >= n {
break;
}
let next_end = line_end(chars, next);
if is_blank(chars, next, next_end) {
break;
}
cur = next;
}
for &s in starts.iter().rev() {
if scan.region_end.contains_key(&s) {
continue;
}
let le = line_end(chars, s);
let region = if le >= n {
le
} else {
let next = le + 1;
if next >= n {
le
} else {
let next_end = line_end(chars, next);
if is_blank(chars, next, next_end) || line_starts_block_scan(chars, next, scan) {
le
} else {
header_region_end_scan(chars, next, scan)
}
}
};
scan.region_end.insert(s, region);
}
scan.region_end
.get(&pos)
.copied()
.unwrap_or_else(|| line_end(chars, pos))
}
fn header_closer(chars: &[char], content_start: usize, line_end: usize, m: usize) -> Option<usize> {
let mut i = content_start;
while i < line_end {
if let Some(next) = skip_construct(chars, i)
&& next > i
{
i = next.min(line_end);
continue;
}
if at(chars, i) == Some('=') {
let mut j = i;
while j < line_end && at(chars, j) == Some('=') {
j += 1;
}
return if j - i >= m { Some(i) } else { None };
}
i += 1;
}
None
}
fn is_hr_line(chars: &[char], pos: usize) -> bool {
let le = line_end(chars, pos);
let mut k = pos;
while k < le && at(chars, k) == Some('-') {
k += 1;
}
k - pos >= 4 && is_blank(chars, k, le)
}
fn split_term(content: &str) -> (String, Option<String>) {
let chars: Vec<char> = content.chars().collect();
let n = chars.len();
let mut i = 0;
while i < n {
if let Some(next) = skip_construct(&chars, i)
&& next > i
{
i = next;
continue;
}
if let Some((_, next)) = bare_url(&chars, i)
&& next > i
{
i = next;
continue;
}
if at(&chars, i) == Some(':') {
let before = collect_range(&chars, 0, i).trim().to_string();
let after = collect_range(&chars, i + 1, n).trim().to_string();
return (before, Some(after));
}
i += 1;
}
(content.trim().to_string(), None)
}
fn skip_construct(chars: &[char], i: usize) -> Option<usize> {
match at(chars, i) {
Some('{') if at(chars, i + 1) == Some('{') => balanced_braces(chars, i),
Some('[') if at(chars, i + 1) == Some('[') => {
find_seq(chars, i + 2, &[']', ']']).map(|c| c + 2)
}
Some('[') => find_char(chars, i + 1, ']').map(|c| c + 1),
Some('<') => find_char(chars, i, '>').map(|c| c + 1),
_ => None,
}
}
fn template_opens(chars: &[char], i: usize) -> bool {
matches!(at(chars, i + 2), Some(c) if c.is_alphanumeric() || c == ':')
}
fn balanced_braces(chars: &[char], i: usize) -> Option<usize> {
let mut depth = 0i32;
let mut j = i;
let n = chars.len();
while j < n {
if at(chars, j) == Some('{') && at(chars, j + 1) == Some('{') {
depth += 1;
j += 2;
} else if at(chars, j) == Some('}') && at(chars, j + 1) == Some('}') {
depth -= 1;
j += 2;
if depth == 0 {
return Some(j);
}
} else {
j += 1;
}
}
None
}
fn is_list_marker(c: char) -> bool {
matches!(c, '*' | '#' | ':' | ';')
}
fn degraded_blocks(chars: &[char]) -> Vec<Block> {
let text = collect_range(chars, 0, chars.len());
let trimmed = text.trim();
if trimmed.is_empty() {
Vec::new()
} else {
vec![Block::Para(vec![Inline::Str(trimmed.into())])]
}
}
fn whitespace_token(chars: &[char], from: usize) -> (Inline, usize) {
let mut i = from;
let mut has_newline = false;
while let Some(w) = at(chars, i) {
if w.is_whitespace() {
if w == '\n' {
has_newline = true;
}
i += 1;
} else {
break;
}
}
let token = if has_newline {
Inline::SoftBreak
} else {
Inline::Space
};
(token, i)
}
fn list_kind(marker: char) -> ListKind {
match marker {
'#' => ListKind::Ordered,
';' | ':' => ListKind::Definition,
_ => ListKind::Bullet,
}
}
fn verbatim_code(
chars: &[char],
name: &str,
after_open: usize,
raw_open: &str,
self_closing: bool,
classes: &[&str],
) -> (Vec<Inline>, usize) {
if self_closing {
return (vec![raw_html(raw_open.to_string())], after_open);
}
match close_tag(chars, after_open, name) {
Some((inner_end, after)) => {
let inner = collect_range(chars, after_open, inner_end);
let attr = Attr {
id: carta_ast::Text::default(),
classes: classes.iter().map(|s| (*s).into()).collect(),
attributes: Vec::new(),
};
(
vec![Inline::Code(Box::new(attr), decode_entities(&inner).into())],
after,
)
}
None => (vec![raw_html(raw_open.to_string())], after_open),
}
}
fn default_list_attrs() -> ListAttributes {
ListAttributes {
start: 1,
style: ListNumberStyle::DefaultStyle,
delim: ListNumberDelim::DefaultDelim,
}
}
fn finish_inline_block(chars: &[char], pos: usize) -> (usize, bool) {
let le = line_end(chars, pos);
if is_blank(chars, pos, le) {
let next = if le < chars.len() { le + 1 } else { le };
(next, true)
} else {
(pos, false)
}
}
fn trim_code(inner: &str) -> String {
let stripped = inner
.strip_prefix("\r\n")
.or_else(|| inner.strip_prefix('\n'))
.unwrap_or(inner);
stripped
.strip_suffix("\r\n")
.or_else(|| stripped.strip_suffix('\n'))
.unwrap_or(stripped)
.to_string()
}
fn flush_word(word: &mut String, toks: &mut Vec<Tok>) {
if !word.is_empty() {
toks.push(Tok::Inline(Inline::Str(std::mem::take(word).into())));
}
}
fn raw_html(text: String) -> Inline {
Inline::RawInline(Format("html".into()), text.into())
}
fn format_mediawiki() -> Format {
Format("mediawiki".into())
}
fn format_html() -> Format {
Format("html".into())
}
fn at(chars: &[char], i: usize) -> Option<char> {
chars.get(i).copied()
}
fn collect_range(chars: &[char], start: usize, end: usize) -> String {
if end <= start {
return String::new();
}
chars.iter().skip(start).take(end - start).collect()
}
fn table_block_end(chars: &[char], pos: usize) -> usize {
let n = chars.len();
let mut depth = 0usize;
let mut line = pos;
loop {
let mut content = line;
while matches!(at(chars, content), Some(' ' | '\t')) {
content += 1;
}
if at(chars, content) == Some('{') && at(chars, content + 1) == Some('|') {
depth += 1;
} else if at(chars, content) == Some('|') && at(chars, content + 1) == Some('}') {
depth = depth.saturating_sub(1);
if depth == 0 {
return content + 2;
}
}
let le = line_end(chars, line);
if le >= n {
return n;
}
line = le + 1;
}
}
fn scan_table_region(region: &str) -> (Vec<Vec<RawCell>>, Option<String>) {
let mut caption_text: Option<String> = None;
let mut rows: Vec<Vec<RawCell>> = Vec::new();
let mut cur: Vec<RawCell> = Vec::new();
let mut open = OpenTarget::None;
let mut nest = 0i32;
let mut lines = region.lines();
lines.next(); for line in lines {
let trimmed = line.trim_start();
if nest > 0 {
if trimmed.starts_with("{|") {
nest += 1;
} else if trimmed.starts_with("|}") {
nest -= 1;
}
append_continuation(open, &mut cur, &mut caption_text, line);
continue;
}
if trimmed.starts_with("|}") {
break;
}
if trimmed.starts_with("{|") {
nest += 1;
append_continuation(open, &mut cur, &mut caption_text, line);
continue;
}
if let Some(rest) = trimmed.strip_prefix("|+") {
caption_text = Some(rest.to_string());
open = OpenTarget::Caption;
continue;
}
if trimmed.starts_with("|-") {
rows.push(std::mem::take(&mut cur));
open = OpenTarget::None;
continue;
}
if let Some(rest) = trimmed.strip_prefix('|') {
cur.extend(parse_cell_line(false, rest));
open = OpenTarget::Cell;
continue;
}
if let Some(rest) = trimmed.strip_prefix('!') {
cur.extend(parse_cell_line(true, rest));
open = OpenTarget::Cell;
continue;
}
append_continuation(open, &mut cur, &mut caption_text, line);
}
rows.push(cur);
(rows, caption_text)
}
fn column_specs(rows: &[Vec<RawCell>], ncols: usize) -> Vec<ColSpec> {
let mut aligns: Vec<Alignment> = Vec::new();
if let Some(first) = rows.first() {
for cell in first {
for _ in 0..col_count(cell.col_span) {
aligns.push(cell.align.clone());
}
}
}
aligns.resize(ncols, Alignment::AlignDefault);
aligns
.into_iter()
.map(|align| ColSpec {
align,
width: ColWidth::ColWidthDefault,
})
.collect()
}
fn col_count(col_span: i32) -> usize {
usize::try_from(col_span.max(1)).unwrap_or(1)
}
fn empty_cell() -> Cell {
Cell {
attr: Attr::default(),
align: Alignment::AlignDefault,
row_span: 1,
col_span: 1,
content: Vec::new(),
}
}
fn append_continuation(
open: OpenTarget,
cur: &mut [RawCell],
caption: &mut Option<String>,
line: &str,
) {
match open {
OpenTarget::Cell => {
if let Some(cell) = cur.last_mut() {
cell.content.push('\n');
cell.content.push_str(line);
}
}
OpenTarget::Caption => {
if let Some(text) = caption {
text.push('\n');
text.push_str(line);
}
}
OpenTarget::None => {}
}
}
fn parse_cell_line(is_header: bool, rest: &str) -> Vec<RawCell> {
split_cells(rest, is_header)
.iter()
.map(|chunk| parse_cell_chunk(is_header, chunk))
.collect()
}
fn split_cells(s: &str, header: bool) -> Vec<String> {
let chars: Vec<char> = s.chars().collect();
let n = chars.len();
let mut out: Vec<String> = Vec::new();
let mut start = 0usize;
let mut square = 0i32;
let mut curly = 0i32;
let mut i = 0usize;
while i < n {
match at(&chars, i) {
Some('[') => square += 1,
Some(']') => square = (square - 1).max(0),
Some('{') => curly += 1,
Some('}') => curly = (curly - 1).max(0),
_ => {}
}
if square == 0 && curly == 0 {
let pipe = at(&chars, i) == Some('|') && at(&chars, i + 1) == Some('|');
let bang = header && at(&chars, i) == Some('!') && at(&chars, i + 1) == Some('!');
if pipe || bang {
out.push(collect_range(&chars, start, i));
i += 2;
start = i;
continue;
}
}
i += 1;
}
out.push(collect_range(&chars, start, n));
out
}
fn parse_cell_chunk(is_header: bool, chunk: &str) -> RawCell {
if let Some(idx) = find_attr_pipe(chunk)
&& let Some(attrs) = parse_cell_attrs(chunk.get(..idx).unwrap_or(""))
{
return RawCell {
is_header,
align: attrs.align,
col_span: attrs.col_span,
row_span: attrs.row_span,
attr: attrs.attr,
content: chunk.get(idx + 1..).unwrap_or("").to_string(),
};
}
RawCell {
is_header,
align: Alignment::AlignDefault,
col_span: 1,
row_span: 1,
attr: Attr::default(),
content: chunk.to_string(),
}
}
fn find_attr_pipe(s: &str) -> Option<usize> {
let mut square = 0i32;
let mut curly = 0i32;
let mut in_quote = false;
for (i, ch) in s.char_indices() {
if in_quote {
if ch == '"' {
in_quote = false;
}
continue;
}
match ch {
'"' => in_quote = true,
'[' => square += 1,
']' => square = (square - 1).max(0),
'{' => curly += 1,
'}' => curly = (curly - 1).max(0),
'|' if square == 0 && curly == 0 => return Some(i),
_ => {}
}
}
None
}
fn parse_cell_attrs(s: &str) -> Option<CellAttrs> {
let chars: Vec<char> = s.chars().collect();
let n = chars.len();
let mut i = 0usize;
let mut id = String::new();
let mut classes: Vec<String> = Vec::new();
let mut attributes: Vec<(String, String)> = Vec::new();
let mut align = Alignment::AlignDefault;
let mut col_span = 1i32;
let mut row_span = 1i32;
let mut any = false;
while i < n {
while at(&chars, i).is_some_and(char::is_whitespace) {
i += 1;
}
if i >= n {
break;
}
let name_start = i;
while at(&chars, i).is_some_and(|c| !c.is_whitespace() && c != '=') {
i += 1;
}
let name = collect_range(&chars, name_start, i);
if name.is_empty() || at(&chars, i) != Some('=') {
return None;
}
i += 1;
let value = if at(&chars, i) == Some('"') {
i += 1;
let value_start = i;
while at(&chars, i).is_some_and(|c| c != '"') {
i += 1;
}
let value = collect_range(&chars, value_start, i);
if at(&chars, i) == Some('"') {
i += 1;
}
value
} else {
let value_start = i;
while at(&chars, i).is_some_and(|c| !c.is_whitespace()) {
i += 1;
}
collect_range(&chars, value_start, i)
};
any = true;
match name.to_ascii_lowercase().as_str() {
"id" => id = value,
"class" => classes.extend(value.split_whitespace().map(str::to_string)),
"align" => match value.to_ascii_lowercase().as_str() {
"left" => align = Alignment::AlignLeft,
"right" => align = Alignment::AlignRight,
"center" => align = Alignment::AlignCenter,
_ => attributes.push(("align".to_string(), value)),
},
"colspan" => match value.trim().parse::<i32>() {
Ok(v) if v >= 1 => col_span = v,
_ => attributes.push(("colspan".to_string(), value)),
},
"rowspan" => match value.trim().parse::<i32>() {
Ok(v) if v >= 1 => row_span = v,
_ => attributes.push(("rowspan".to_string(), value)),
},
_ => attributes.push((name, value)),
}
}
if !any {
return None;
}
Some(CellAttrs {
align,
col_span,
row_span,
attr: Attr {
id: id.into(),
classes: classes.into_iter().map(Into::into).collect(),
attributes: attributes
.into_iter()
.map(|(k, v)| (k.into(), v.into()))
.collect(),
},
})
}
fn line_end(chars: &[char], pos: usize) -> usize {
find_char(chars, pos, '\n').unwrap_or(chars.len())
}
fn is_blank(chars: &[char], start: usize, end: usize) -> bool {
(start..end).all(|j| at(chars, j).is_none_or(char::is_whitespace))
}
fn find_char(chars: &[char], from: usize, target: char) -> Option<usize> {
(from..chars.len()).find(|&j| at(chars, j) == Some(target))
}
fn find_seq(chars: &[char], from: usize, seq: &[char]) -> Option<usize> {
let n = chars.len();
let m = seq.len();
if m == 0 || n < m {
return None;
}
(from..=n - m).find(|&j| (0..m).all(|k| at(chars, j + k) == seq.get(k).copied()))
}
fn scan_link_target(chars: &[char], start: usize) -> Option<(usize, bool)> {
let mut i = start;
while let Some(c) = at(chars, i) {
if c == '|' {
return Some((i, true));
}
if c == ']' && at(chars, i + 1) == Some(']') {
return Some((i, false));
}
i += 1;
}
None
}
fn find_link_close(chars: &[char], start: usize) -> Option<usize> {
let mut depth = 0usize;
let mut i = start;
while let Some(c) = at(chars, i) {
if c == '[' && at(chars, i + 1) == Some('[') {
depth += 1;
i += 2;
} else if c == ']' && at(chars, i + 1) == Some(']') {
if depth == 0 {
return Some(i);
}
depth -= 1;
i += 2;
} else {
i += 1;
}
}
None
}
fn matches_prefix_ci(chars: &[char], i: usize, prefix: &str) -> bool {
prefix
.chars()
.enumerate()
.all(|(k, pc)| match at(chars, i + k) {
Some(c) => c.eq_ignore_ascii_case(&pc),
None => false,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(input: &str) -> Vec<Block> {
let mut options = ReaderOptions::default();
options.extensions = Extensions::from_list(&[Extension::AutoIdentifiers]);
MediawikiReader
.read(input, &options)
.expect("read should not fail")
.blocks
}
fn parse_gfm(input: &str) -> Vec<Block> {
let mut options = ReaderOptions::default();
options.extensions = Extensions::from_list(&[Extension::GfmAutoIdentifiers]);
MediawikiReader.read(input, &options).expect("read").blocks
}
#[test]
fn doi_and_javascript_are_recognized_schemes() {
assert!(is_scheme("doi"));
assert!(is_scheme("javascript"));
assert!(is_scheme("DOI"));
assert!(is_scheme("http"));
assert!(!is_scheme("notascheme"));
}
fn cell_with(content: Vec<Block>) -> Cell {
Cell {
attr: Attr::default(),
align: Alignment::AlignDefault,
row_span: 1,
col_span: 1,
content,
}
}
fn data_cell(text: &str) -> Cell {
cell_with(vec![Block::Para(vec![Inline::Str(text.into())])])
}
fn table_row(cells: Vec<Cell>) -> Row {
Row {
attr: Attr::default(),
cells,
}
}
fn default_col() -> ColSpec {
ColSpec {
align: Alignment::AlignDefault,
width: ColWidth::ColWidthDefault,
}
}
#[test]
fn table_markup_becomes_a_table() {
assert_eq!(
parse("{|\n! Header\n|-\n| Cell\n|}\nafter"),
vec![
Block::Table(Box::new(Table {
col_specs: vec![default_col()],
head: TableHead {
rows: vec![table_row(vec![data_cell("Header")])],
..Default::default()
},
bodies: vec![TableBody {
body: vec![table_row(vec![data_cell("Cell")])],
..Default::default()
}],
..Default::default()
})),
Block::Para(vec![Inline::Str("after".into())]),
]
);
}
#[test]
fn unterminated_table_markup_does_not_panic() {
assert_eq!(
parse("{|"),
vec![Block::Table(Box::new(Table {
bodies: vec![TableBody {
body: vec![table_row(Vec::new())],
..Default::default()
}],
..Default::default()
}))]
);
}
#[test]
fn nested_table_markup_closes_at_the_outer_marker() {
let inner = Block::Table(Box::new(Table {
col_specs: vec![default_col()],
bodies: vec![TableBody {
body: vec![table_row(vec![data_cell("inner")])],
..Default::default()
}],
..Default::default()
}));
assert_eq!(
parse("{|\n|\n{|\n| inner\n|}\n|}"),
vec![Block::Table(Box::new(Table {
col_specs: vec![default_col()],
bodies: vec![TableBody {
body: vec![table_row(vec![cell_with(vec![inner])])],
..Default::default()
}],
..Default::default()
}))]
);
}
#[test]
fn paragraph_joins_lines_with_soft_breaks() {
assert_eq!(
parse("one two\nthree"),
vec![Block::Para(vec![
Inline::Str("one".into()),
Inline::Space,
Inline::Str("two".into()),
Inline::SoftBreak,
Inline::Str("three".into()),
])]
);
}
#[test]
fn emphasis_runs_decompose() {
assert_eq!(
parse("''i'' '''b''' '''''both'''''"),
vec![Block::Para(vec![
Inline::Emph(vec![Inline::Str("i".into())]),
Inline::Space,
Inline::Strong(vec![Inline::Str("b".into())]),
Inline::Space,
Inline::Strong(vec![Inline::Emph(vec![Inline::Str("both".into())])]),
])]
);
}
#[test]
fn header_carries_mediawiki_identifier() {
assert_eq!(
parse("== Hello World =="),
vec![Block::Header(
2,
Box::new(Attr {
id: "hello_world".into(),
classes: vec![],
attributes: vec![],
}),
vec![
Inline::Str("Hello".into()),
Inline::Space,
Inline::Str("World".into()),
],
)]
);
}
#[test]
fn duplicate_identifiers_are_suffixed() {
let blocks = parse("== Dup ==\n== Dup ==");
let ids: Vec<String> = blocks
.iter()
.filter_map(|b| match b {
Block::Header(_, attr, _) => Some(attr.id.to_string()),
_ => None,
})
.collect();
assert_eq!(ids, vec!["dup".to_string(), "dup_1".to_string()]);
}
#[test]
fn gfm_identifier_scheme_uses_hyphens() {
let blocks = parse_gfm("== Hello World ==");
match blocks.first() {
Some(Block::Header(_, attr, _)) => assert_eq!(attr.id, "hello-world"),
other => panic!("expected header, got {other:?}"),
}
}
#[test]
fn empty_identifier_falls_back_to_section() {
let blocks = parse("== !!! ==\n== ??? ==");
let ids: Vec<String> = blocks
.iter()
.filter_map(|b| match b {
Block::Header(_, attr, _) => Some(attr.id.to_string()),
_ => None,
})
.collect();
assert_eq!(ids, vec!["section".to_string(), "section_1".to_string()]);
}
#[test]
fn malformed_header_is_a_paragraph() {
assert_eq!(
parse("== a=b =="),
vec![Block::Para(vec![
Inline::Str("==".into()),
Inline::Space,
Inline::Str("a=b".into()),
Inline::Space,
Inline::Str("==".into()),
])]
);
}
#[test]
fn header_leftover_becomes_paragraph() {
assert_eq!(
parse("== H ==="),
vec![
Block::Header(
2,
Box::new(Attr {
id: "h".into(),
classes: vec![],
attributes: vec![],
}),
vec![Inline::Str("H".into())],
),
Block::Para(vec![Inline::Str("=".into())]),
]
);
}
#[test]
fn nested_bullets_and_ordered() {
assert_eq!(
parse("* a\n** b\n*# c"),
vec![Block::BulletList(vec![vec![
Block::Plain(vec![Inline::Str("a".into())]),
Block::BulletList(vec![vec![Block::Plain(vec![Inline::Str("b".into())])]]),
Block::OrderedList(
default_list_attrs(),
vec![vec![Block::Plain(vec![Inline::Str("c".into())])]]
),
]])]
);
}
#[test]
fn definition_list_splits_inline_definition() {
assert_eq!(
parse("; term : def"),
vec![Block::DefinitionList(vec![(
vec![Inline::Str("term".into())],
vec![vec![Block::Plain(vec![Inline::Str("def".into())])]],
)])]
);
}
#[test]
fn internal_link_with_trail() {
assert_eq!(
parse("[[Page]]s"),
vec![Block::Para(vec![Inline::Link(
Box::new(Attr {
id: carta_ast::Text::default(),
classes: vec!["wikilink".into()],
attributes: vec![],
}),
vec![Inline::Str("Pages".into())],
Box::new(Target {
url: "Page".into(),
title: "Page".into(),
}),
)])]
);
}
#[test]
fn lone_file_embed_becomes_a_figure() {
assert_eq!(
parse("[[File:Foo.jpg|thumb|A caption]]"),
vec![Block::Figure(
Box::default(),
Box::new(Caption {
short: None,
long: vec![Block::Plain(vec![
Inline::Str("A".into()),
Inline::Space,
Inline::Str("caption".into()),
])],
}),
vec![Block::Plain(vec![Inline::Image(
Box::default(),
vec![],
Box::new(Target {
url: "Foo.jpg".into(),
title: "A caption".into(),
}),
)])],
)]
);
}
#[test]
fn embed_without_caption_defaults_to_the_file_name() {
assert_eq!(
parse("[[Image:My Photo.jpg]]"),
vec![Block::Figure(
Box::default(),
Box::new(Caption {
short: None,
long: vec![Block::Plain(vec![Inline::Str("My_Photo.jpg".into())])],
}),
vec![Block::Plain(vec![Inline::Image(
Box::default(),
vec![],
Box::new(Target {
url: "My_Photo.jpg".into(),
title: "My_Photo.jpg".into(),
}),
)])],
)]
);
}
#[test]
fn embed_size_parameters_set_width_and_height() {
assert_eq!(
parse("[[File:Foo.jpg|100x200px|cap]]"),
vec![Block::Figure(
Box::default(),
Box::new(Caption {
short: None,
long: vec![Block::Plain(vec![Inline::Str("cap".into())])],
}),
vec![Block::Plain(vec![Inline::Image(
Box::new(Attr {
id: carta_ast::Text::default(),
classes: vec![],
attributes: vec![
("width".into(), "100".into()),
("height".into(), "200".into()),
],
}),
vec![],
Box::new(Target {
url: "Foo.jpg".into(),
title: "cap".into(),
}),
)])],
)]
);
}
#[test]
fn inline_embed_stays_an_image_not_a_figure() {
assert_eq!(
parse("x [[File:Foo.jpg|cap]]"),
vec![Block::Para(vec![
Inline::Str("x".into()),
Inline::Space,
Inline::Image(
Box::default(),
vec![Inline::Str("cap".into())],
Box::new(Target {
url: "Foo.jpg".into(),
title: "cap".into(),
}),
),
])]
);
}
#[test]
fn empty_file_embed_is_an_ordinary_wikilink() {
assert_eq!(
parse("[[File:]]"),
vec![Block::Para(vec![Inline::Link(
Box::new(Attr {
id: carta_ast::Text::default(),
classes: vec!["wikilink".into()],
attributes: vec![],
}),
vec![Inline::Str("File:".into())],
Box::new(Target {
url: "File:".into(),
title: "File:".into(),
}),
)])]
);
}
#[test]
fn external_links_number_and_label() {
assert_eq!(
parse("[http://x.com lbl] [http://y.com]"),
vec![Block::Para(vec![
Inline::Link(
Box::default(),
vec![Inline::Str("lbl".into())],
Box::new(Target {
url: "http://x.com".into(),
title: carta_ast::Text::default(),
}),
),
Inline::Space,
Inline::Link(
Box::default(),
vec![Inline::Str("1".into())],
Box::new(Target {
url: "http://y.com".into(),
title: carta_ast::Text::default(),
}),
),
])]
);
}
#[test]
fn bare_url_trims_trailing_punctuation() {
assert_eq!(
parse("see http://x.com."),
vec![Block::Para(vec![
Inline::Str("see".into()),
Inline::Space,
Inline::Link(
Box::default(),
vec![Inline::Str("http://x.com".into())],
Box::new(Target {
url: "http://x.com".into(),
title: carta_ast::Text::default(),
}),
),
Inline::Str(".".into()),
])]
);
}
#[test]
fn entities_are_decoded_in_text() {
assert_eq!(
parse("AT&T ©"),
vec![Block::Para(vec![
Inline::Str("AT&T".into()),
Inline::Space,
Inline::Str("\u{a9}".into()),
])]
);
}
#[test]
fn nowiki_is_literal_text() {
assert_eq!(
parse("<nowiki>'''raw'''</nowiki>"),
vec![Block::Para(vec![Inline::Str("'''raw'''".into())])]
);
}
#[test]
fn reference_becomes_a_note() {
assert_eq!(
parse("x<ref>note</ref>"),
vec![Block::Para(vec![
Inline::Str("x".into()),
Inline::Note(vec![Block::Plain(vec![Inline::Str("note".into())])]),
])]
);
}
#[test]
fn code_tag_decodes_entities() {
assert_eq!(
parse("<code>a & b</code>"),
vec![Block::Para(vec![Inline::Code(
Box::default(),
"a & b".into()
)])]
);
}
#[test]
fn unknown_tag_passes_through_as_raw_html() {
assert_eq!(
parse("<b>x</b>"),
vec![Block::Para(vec![
raw_html("<b>".into()),
Inline::Str("x".into()),
raw_html("</b>".into()),
])]
);
}
#[test]
fn whole_line_comment_is_removed_with_its_newline() {
assert_eq!(
parse("x\n<!--c-->\ny"),
vec![Block::Para(vec![
Inline::Str("x".into()),
Inline::SoftBreak,
Inline::Str("y".into()),
])]
);
}
#[test]
fn inline_comment_becomes_a_space() {
assert_eq!(
parse("a<!--c-->b"),
vec![Block::Para(vec![
Inline::Str("a".into()),
Inline::Space,
Inline::Str("b".into()),
])]
);
}
#[test]
fn syntax_highlight_block_keeps_language_and_content() {
assert_eq!(
parse("<syntaxhighlight lang=\"rust\">\nfn main(){}\n</syntaxhighlight>"),
vec![Block::CodeBlock(
Box::new(Attr {
id: carta_ast::Text::default(),
classes: vec!["rust".into()],
attributes: vec![],
}),
"fn main(){}".into(),
)]
);
}
#[test]
fn horizontal_rule_requires_a_dashes_only_line() {
assert_eq!(parse("----"), vec![Block::HorizontalRule]);
assert_eq!(
parse("----foo"),
vec![Block::Para(vec![Inline::Str("----foo".into())])]
);
}
#[test]
fn preformatted_lines_become_code() {
assert_eq!(
parse(" indented line"),
vec![Block::Para(vec![Inline::Code(
Box::default(),
"indented\u{a0}\u{a0}line".into()
)])]
);
}
#[test]
fn preformatted_preserves_markup_and_spacing() {
assert_eq!(
parse(" a '''b''' c"),
vec![Block::Para(vec![
Inline::Code(Box::default(), "a\u{a0}".into()),
Inline::Strong(vec![Inline::Code(Box::default(), "b".into())]),
Inline::Code(Box::default(), "\u{a0}c".into()),
])]
);
}
#[test]
fn block_template_is_raw_then_trailing_paragraph() {
assert_eq!(
parse("{{tpl}} trailing"),
vec![
Block::RawBlock(format_mediawiki(), "{{tpl}}".into()),
Block::Para(vec![Inline::Str("trailing".into())]),
]
);
}
fn reads_ok(input: &str) -> bool {
MediawikiReader
.read(input, &ReaderOptions::default())
.is_ok()
}
#[test]
fn adversarially_nested_wiki_list_does_not_panic() {
let mut input = String::new();
for n in 1..4000 {
input.push_str(&"*".repeat(n));
input.push_str(" item\n");
}
assert!(reads_ok(&input));
let single = format!("{} item", "*".repeat(20_000));
assert!(reads_ok(&single));
}
#[test]
fn adversarially_nested_tables_do_not_panic() {
let input = format!("{}| x\n{}", "{|\n".repeat(4000), "|}\n".repeat(4000));
assert!(reads_ok(&input));
}
#[test]
fn adversarially_nested_html_list_does_not_panic() {
let input = format!("{}x{}", "<ul><li>".repeat(4000), "</li></ul>".repeat(4000));
assert!(reads_ok(&input));
}
#[test]
fn adversarially_nested_refs_do_not_panic() {
let input = format!("{}x{}", "a<ref>".repeat(4000), "</ref>".repeat(4000));
assert!(reads_ok(&input));
}
#[test]
fn stacked_header_lines_do_not_blow_up() {
let input = "== ~iT\n= w e\n= J".repeat(4000);
assert!(reads_ok(&input));
}
}