use pulldown_cmark::{
Alignment, BlockQuoteKind, CodeBlockKind, Event, LinkType, Options, Parser, Tag, TagEnd,
};
use std::ops::Range;
use crate::{
doc::{Align, Block, BlockKind, Doc, Form, Mark, MarkSpan, QuoteKind, Text},
marks::Marks,
select::Cursor,
};
pub(crate) const OPTIONS: Options = Options::ENABLE_TABLES
.union(Options::ENABLE_STRIKETHROUGH)
.union(Options::ENABLE_TASKLISTS)
.union(Options::ENABLE_GFM);
impl From<BlockQuoteKind> for QuoteKind {
fn from(kind: BlockQuoteKind) -> Self {
match kind {
BlockQuoteKind::Note => Self::Note,
BlockQuoteKind::Tip => Self::Tip,
BlockQuoteKind::Important => Self::Important,
BlockQuoteKind::Warning => Self::Warning,
BlockQuoteKind::Caution => Self::Caution,
}
}
}
pub fn parse(source: &str) -> Doc {
parse_plain(source)
}
pub fn parse_with(source: &str, marks: &Marks) -> Doc {
if marks.is_empty() {
return parse_plain(source);
}
let mut doc = parse_plain(&lift(source, marks));
for block in &mut doc.blocks {
for part in block.parts() {
if let Some(text) = block.text_at_mut(part) {
settle(text, marks);
}
}
}
doc
}
fn parse_plain(source: &str) -> Doc {
let mut state = ParseState::default();
for event in Parser::new_ext(source, OPTIONS) {
state.event(event);
}
state.doc.renumber();
state.doc
}
#[derive(Default)]
struct TextBuilder {
text: String,
marks: Vec<MarkSpan>,
open: Vec<usize>,
}
impl TextBuilder {
fn open(&mut self, mark: Mark) {
let ix = self.marks.len();
let at = self.text.len();
self.marks.push(MarkSpan {
range: at..at,
mark,
});
self.open.push(ix);
}
fn is_empty(&self) -> bool {
self.text.is_empty() && self.marks.is_empty()
}
fn close(&mut self) {
if let Some(ix) = self.open.pop() {
self.marks[ix].range.end = self.text.len();
}
}
fn wrap(&mut self, mark: Mark, s: &str) {
let start = self.text.len();
self.text.push_str(s);
self.marks.push(MarkSpan {
range: start..self.text.len(),
mark,
});
}
fn take(&mut self) -> Text {
self.open.clear();
let mut text = normalize(
&std::mem::take(&mut self.text),
&std::mem::take(&mut self.marks),
);
settle_mentions(&mut text);
linkify(&mut text);
text
}
}
fn settle_mentions(text: &mut Text) {
let settled: Vec<usize> = (0..text.marks.len())
.filter(|ix| {
matches!(
text.marks[*ix].mark,
Mark::Mention {
form: Form::Auto,
..
}
) && !is_shorthand(text, *ix)
})
.collect();
for ix in settled {
if let Mark::Mention { form, .. } = &mut text.marks[ix].mark {
*form = Form::Chip;
}
}
}
pub(crate) fn is_shorthand(text: &Text, ix: usize) -> bool {
let span = &text.marks[ix];
let Mark::Mention { url, form } = &span.mark else {
return false;
};
*form == Form::Auto
&& text.text.get(span.range.clone()) == Some(url.as_str())
&& is_url(url)
&& text.alone(ix)
}
const SCHEMES: [&str; 2] = ["https://", "http://"];
pub(crate) fn urls(text: &str) -> Vec<Range<usize>> {
let mut found = Vec::new();
let mut at = 0;
while at < text.len() {
let Some((start, scheme)) = SCHEMES
.iter()
.filter_map(|scheme| text[at..].find(scheme).map(|ix| (at + ix, *scheme)))
.min_by_key(|(ix, _)| *ix)
else {
break;
};
let stop = text[start..]
.find(char::is_whitespace)
.map_or(text.len(), |ix| start + ix);
let end = start + trim_url(&text[start..stop]);
let opens = text[..start]
.chars()
.next_back()
.is_none_or(|c| !c.is_alphanumeric());
if opens && end > start + scheme.len() {
found.push(start..end);
}
at = stop.max(start + 1);
}
found
}
pub fn is_url(source: &str) -> bool {
matches!(urls(source).as_slice(), [only] if *only == (0..source.len()))
}
pub fn is_image(source: &str) -> bool {
let path = source.split(['?', '#']).next().unwrap_or(source);
let Some((_, extension)) = path.rsplit_once('.') else {
return false;
};
matches!(
extension.to_ascii_lowercase().as_str(),
"png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "ico" | "tif" | "tiff" | "avif"
)
}
fn trim_url(run: &str) -> usize {
let mut end = run.len();
while let Some(last) = run[..end].chars().next_back() {
let keep = match last {
'.' | ',' | ';' | ':' | '!' | '?' | '\'' | '"' => false,
')' => run[..end].matches('(').count() >= run[..end].matches(')').count(),
']' => run[..end].matches('[').count() >= run[..end].matches(']').count(),
_ => true,
};
if keep {
break;
}
end -= last.len_utf8();
}
end
}
fn linkify(text: &mut Text) {
let fresh: Vec<Range<usize>> = urls(&text.text)
.into_iter()
.filter(|range| {
!text.marks.iter().any(|span| {
matches!(
span.mark,
Mark::Link(_) | Mark::Mention { .. } | Mark::Image(_) | Mark::Code
) && span.range.start < range.end
&& range.start < span.range.end
})
})
.collect();
for range in fresh {
let url = text.text[range.clone()].to_string();
text.marks.push(MarkSpan {
range,
mark: Mark::Link(url),
});
}
}
pub(crate) fn normalize(text: &str, marks: &[MarkSpan]) -> Text {
let bytes = text.as_bytes();
let mut keep = vec![true; text.len()];
let mut line_begin = 0;
for offset in memchr_newlines(text).chain([text.len()]) {
let line = &text[line_begin..offset];
let lead = line.len() - line.trim_start_matches([' ', '\t']).len();
let trail = line.len() - line.trim_end_matches([' ', '\t']).len();
keep[line_begin..line_begin + lead].fill(false);
keep[offset - trail..offset].fill(false);
line_begin = offset + 1;
}
let mut head = 0;
while head < text.len() && (!keep[head] || bytes[head] == b'\n') {
keep[head] = false;
head += 1;
}
let mut tail = text.len();
while tail > 0 && (!keep[tail - 1] || bytes[tail - 1] == b'\n') {
keep[tail - 1] = false;
tail -= 1;
}
let mut out = String::with_capacity(text.len());
let mut map = vec![0; text.len() + 1];
for (offset, ch) in text.char_indices() {
map[offset] = out.len();
if keep[offset] {
out.push(ch);
}
}
map[text.len()] = out.len();
let marks = marks
.iter()
.map(|span| MarkSpan {
range: map[span.range.start]..map[span.range.end],
mark: span.mark.clone(),
})
.filter(|span| !span.range.is_empty() || matches!(span.mark, Mark::Image(_)))
.collect();
Text {
text: out,
marks: merge_same_mark(marks),
}
}
fn merge_same_mark(mut marks: Vec<MarkSpan>) -> Vec<MarkSpan> {
let mut ix = 0;
while ix < marks.len() {
let mut fused = None;
for other in ix + 1..marks.len() {
let (a, b) = (&marks[ix], &marks[other]);
if a.mark == b.mark
&& !matches!(a.mark, Mark::Image(_) | Mark::Mention { .. })
&& a.range.start <= b.range.end
&& b.range.start <= a.range.end
{
fused = Some((
other,
a.range.start.min(b.range.start),
a.range.end.max(b.range.end),
));
break;
}
}
match fused {
Some((other, start, end)) => {
marks[ix].range = start..end;
marks.remove(other);
}
None => ix += 1,
}
}
marks
}
pub(crate) fn collapse_to_one_line(text: &mut Text) {
if text.text.contains('\n') {
text.text = text.text.replace('\n', " ");
}
}
fn split_width(alt: &str) -> (&str, Option<u32>) {
let Some((caption, tail)) = alt.rsplit_once('|') else {
return (alt, None);
};
match tail.parse().ok().filter(|width| *width > 0) {
Some(width) => (caption, Some(width)),
None => (alt, None),
}
}
fn memchr_newlines(text: &str) -> impl Iterator<Item = usize> + '_ {
text.bytes()
.enumerate()
.filter_map(|(ix, b)| (b == b'\n').then_some(ix))
}
#[derive(Clone, Copy)]
enum Marker {
Bullet,
Ordered(u64),
Task(bool),
}
impl Marker {
fn into_kind(self, text: Text) -> BlockKind {
match self {
Self::Bullet => BlockKind::Bullet(text),
Self::Ordered(number) => BlockKind::Ordered { number, text },
Self::Task(checked) => BlockKind::Task { checked, text },
}
}
}
#[derive(Default)]
struct TableBuild {
align: Vec<Align>,
header: Vec<Text>,
rows: Vec<Vec<Text>>,
row: Vec<Text>,
in_head: bool,
}
struct OpenQuote {
kind: Option<QuoteKind>,
at: usize,
}
#[derive(Default)]
struct ParseState {
doc: Doc,
builder: TextBuilder,
lists: Vec<Option<u64>>,
quotes: Vec<OpenQuote>,
pending_marker: Option<Marker>,
heading: Option<u8>,
code: Option<(Option<String>, String)>,
table: Option<TableBuild>,
}
impl ParseState {
fn indent(&self) -> u8 {
self.lists.len() as u8
}
fn quote_kind(&self) -> Option<QuoteKind> {
self.quotes.last().and_then(|open| open.kind)
}
fn push(&mut self, kind: BlockKind, indent: u8) {
let max = self.doc.blocks.last().map_or(0, |b| b.indent + 1);
self.doc.blocks.push(Block {
kind,
indent: indent.min(max),
});
}
fn flush_marker(&mut self) {
let Some(marker) = self.pending_marker.take() else {
return;
};
let indent = self.indent().saturating_sub(1);
self.push(marker.into_kind(Text::default()), indent);
}
fn flush_inline(&mut self) {
if self.table.is_none() && !self.builder.is_empty() {
self.finish_paragraph();
}
}
fn finish_paragraph(&mut self) {
let text = self.builder.take();
if let [
MarkSpan {
range,
mark: Mark::Image(url),
},
] = text.marks.as_slice()
&& range.start == 0
&& range.end == text.text.len()
{
let (caption, width) = split_width(&text.text);
let (url, alt) = (url.clone(), Text::plain(caption.to_string()));
self.flush_marker();
let indent = self.indent();
self.push(BlockKind::Image { url, alt, width }, indent);
return;
}
if let [
MarkSpan {
range,
mark: Mark::Mention { url, form },
},
] = text.marks.as_slice()
&& range.start == 0
&& range.end == text.text.len()
&& text.text == *url
&& is_url(url)
{
let (url, form) = (url.clone(), *form);
self.flush_marker();
let indent = self.indent();
self.push(BlockKind::Bookmark { url, form }, indent);
return;
}
if !self.quotes.is_empty() {
self.flush_marker();
let kind = self.quote_kind();
let indent = self.indent();
self.push(BlockKind::Quote { kind, text }, indent);
} else if let Some(marker) = self.pending_marker.take() {
let indent = self.indent().saturating_sub(1);
self.push(marker.into_kind(text), indent);
} else {
let indent = self.indent();
self.push(BlockKind::Paragraph(text), indent);
}
}
fn event(&mut self, event: Event<'_>) {
match event {
Event::Start(tag) => self.start(tag),
Event::End(tag) => self.end(tag),
Event::Text(t) => match &mut self.code {
Some((_, code)) => code.push_str(&t),
None => self.builder.text.push_str(&t),
},
Event::Code(t) => self.builder.wrap(Mark::Code, &t),
Event::Html(t) | Event::InlineHtml(t) => self.builder.text.push_str(&t),
Event::SoftBreak | Event::HardBreak => match &mut self.code {
Some((_, code)) => code.push('\n'),
None => self.builder.text.push('\n'),
},
Event::Rule => {
self.flush_inline();
self.flush_marker();
let indent = self.indent();
self.push(BlockKind::Rule, indent);
}
Event::TaskListMarker(checked) => {
self.pending_marker = Some(Marker::Task(checked));
}
Event::FootnoteReference(label) => {
self.builder.text.push_str(&format!("[^{label}]"));
}
_ => {}
}
}
fn start(&mut self, tag: Tag<'_>) {
match tag {
Tag::Heading { level, .. } => {
self.flush_inline();
self.heading = Some(level as u8);
}
Tag::BlockQuote(kind) => {
self.flush_inline();
let at = self.doc.blocks.len();
self.quotes.push(OpenQuote {
kind: kind.map(QuoteKind::from),
at,
});
}
Tag::CodeBlock(kind) => {
self.flush_inline();
self.flush_marker();
let language = match kind {
CodeBlockKind::Fenced(info) => {
let tag = info.split_whitespace().next().unwrap_or("");
(!tag.is_empty()).then(|| tag.to_string())
}
CodeBlockKind::Indented => None,
};
self.code = Some((language, String::new()));
}
Tag::List(start) => {
self.flush_inline();
self.flush_marker();
self.lists.push(start);
}
Tag::Item => {
self.flush_inline();
self.pending_marker = Some(match self.lists.last_mut() {
Some(Some(number)) => {
let n = *number;
*number += 1;
Marker::Ordered(n)
}
_ => Marker::Bullet,
});
}
Tag::Table(aligns) => {
self.flush_inline();
self.flush_marker();
self.table = Some(TableBuild {
align: aligns.iter().map(align_of).collect(),
..TableBuild::default()
});
}
Tag::TableHead => {
if let Some(table) = &mut self.table {
table.in_head = true;
}
}
Tag::Emphasis => {
self.builder.open(Mark::Italic);
}
Tag::Strong => {
self.builder.open(Mark::Bold);
}
Tag::Strikethrough => {
self.builder.open(Mark::Strike);
}
Tag::Link {
link_type,
dest_url,
title,
..
} => {
let url = dest_url.into_string();
let form = match link_type {
LinkType::Autolink => Some(Form::Auto),
_ => Form::from_title(&title),
};
self.builder.open(match form {
Some(form) => Mark::Mention { url, form },
None => Mark::Link(url),
});
}
Tag::Image { dest_url, .. } => {
self.builder.open(Mark::Image(dest_url.into_string()));
}
_ => {}
}
}
fn end(&mut self, tag: TagEnd) {
match tag {
TagEnd::Paragraph | TagEnd::HtmlBlock => self.flush_inline(),
TagEnd::Heading(_) => {
self.flush_marker();
let level = self.heading.take().unwrap_or(1);
let mut text = self.builder.take();
collapse_to_one_line(&mut text);
let indent = self.indent();
self.push(BlockKind::Heading { level, text }, indent);
}
TagEnd::BlockQuote(_) => {
self.flush_inline();
if let Some(open) = self.quotes.pop()
&& open.kind.is_some()
&& self.doc.blocks.len() == open.at
{
let indent = self.indent();
self.push(
BlockKind::Quote {
kind: open.kind,
text: Text::default(),
},
indent,
);
}
}
TagEnd::CodeBlock => {
if let Some((language, code)) = self.code.take() {
let indent = self.indent();
let code = code.strip_suffix('\n').map_or(code.clone(), str::to_string);
self.push(
BlockKind::Code {
language,
code: Text::plain(code),
},
indent,
);
}
}
TagEnd::List(_) => {
self.flush_inline();
self.lists.pop();
}
TagEnd::Item => {
self.flush_inline();
self.flush_marker();
}
TagEnd::Table => {
if let Some(table) = self.table.take() {
let indent = self.indent();
self.push(
BlockKind::Table {
align: table.align,
header: table.header,
rows: table.rows,
},
indent,
);
}
}
TagEnd::TableHead => {
if let Some(table) = &mut self.table {
table.header = std::mem::take(&mut table.row);
table.in_head = false;
}
}
TagEnd::TableRow => {
if let Some(table) = &mut self.table {
let row = std::mem::take(&mut table.row);
table.rows.push(row);
}
}
TagEnd::TableCell => {
let mut cell = self.builder.take();
collapse_to_one_line(&mut cell);
if let Some(table) = &mut self.table {
table.row.push(cell);
}
}
TagEnd::Emphasis | TagEnd::Strong | TagEnd::Strikethrough | TagEnd::Link => {
self.builder.close();
}
TagEnd::Image => self.builder.close(),
_ => {}
}
}
}
fn align_of(alignment: &Alignment) -> Align {
match alignment {
Alignment::Center => Align::Center,
Alignment::Right => Align::Right,
Alignment::Left | Alignment::None => Align::Left,
}
}
pub fn parse_at(source: &str, offset: usize, marks: &Marks) -> (Doc, Cursor) {
let plain = parse_with(source, marks);
let start = || (plain.clone(), Cursor::default().clamp(&plain));
if source.contains(crate::serialize::SENTINEL) {
return start();
}
let mut marked = String::with_capacity(source.len() + 3);
let offset = offset.min(source.len());
if !source.is_char_boundary(offset) {
return start();
}
marked.push_str(&source[..offset]);
marked.push(crate::serialize::SENTINEL);
marked.push_str(&source[offset..]);
let mut doc = parse_with(&marked, marks);
let Some(at) = find(&doc) else { return start() };
let Some(text) = doc
.blocks
.get_mut(at.block)
.and_then(|block| block.text_at_mut(at.part))
else {
return start();
};
text.remove(at.offset..at.offset + crate::serialize::SENTINEL.len_utf8());
if doc != plain { start() } else { (doc, at) }
}
fn find(doc: &Doc) -> Option<Cursor> {
doc.blocks.iter().enumerate().find_map(|(ix, block)| {
block.parts().into_iter().find_map(|part| {
let at = block.text_at(part)?.text.find(crate::serialize::SENTINEL)?;
Some(Cursor::new(ix, part, at))
})
})
}
const OPEN: char = '\u{E010}';
const CLOSE: char = '\u{E011}';
fn tag(ix: usize) -> Option<char> {
char::from_u32(0xE020 + u32::try_from(ix).ok()?).filter(|_| ix < 0x100)
}
fn tag_index(c: char) -> Option<usize> {
(0xE020..0xE120)
.contains(&(c as u32))
.then(|| c as usize - 0xE020)
}
fn lift(source: &str, marks: &Marks) -> String {
let skipped = literal(source);
let entries = marks.sorted();
let mut out = String::with_capacity(source.len());
let mut open: Vec<(usize, &str)> = Vec::new();
let mut at = 0usize;
while at < source.len() {
if let Some(range) = skipped.iter().find(|range| range.contains(&at)) {
out.push_str(&source[at..range.end]);
at = range.end;
continue;
}
let rest = &source[at..];
if let Some(escaped) = rest.strip_prefix('\\') {
let width = escaped.chars().next().map_or(1, |c| 1 + c.len_utf8());
out.push_str(&rest[..width.min(rest.len())]);
at += width.min(rest.len());
continue;
}
let found = entries
.iter()
.find(|entry| rest.starts_with(entry.delimiter.as_ref()));
if let Some(entry) = found {
let delimiter: &str = entry.delimiter.as_ref();
let closes = open.last().is_some_and(|(_, open)| *open == delimiter);
if closes && !source[..at].ends_with(char::is_whitespace) {
out.push(CLOSE);
open.pop();
at += delimiter.len();
continue;
}
if !closes
&& let Some(ix) = marks.position(entry)
&& let Some(tag) = tag(ix)
&& closing(
source,
at + delimiter.len(),
delimiter,
&skipped,
line_end(source, at),
)
{
out.push(OPEN);
out.push(tag);
open.push((ix, delimiter));
at += delimiter.len();
continue;
}
}
let c = rest.chars().next().unwrap_or_default();
out.push(c);
at += c.len_utf8();
}
out
}
fn closing(
source: &str,
from: usize,
delimiter: &str,
skipped: &[Range<usize>],
line_end: usize,
) -> bool {
if source[from..].starts_with(char::is_whitespace) {
return false;
}
let mut at = from;
while let Some(found) = source[at..line_end.max(at)].find(delimiter) {
let found = at + found;
let escaped = source[..found].ends_with('\\');
let literal = skipped.iter().any(|range| range.contains(&found));
let spaced = source[..found].ends_with(char::is_whitespace);
if !escaped && !literal && !spaced && found > from {
return true;
}
at = found + delimiter.len();
}
false
}
fn line_end(source: &str, at: usize) -> usize {
source[at..].find('\n').map_or(source.len(), |ix| at + ix)
}
fn literal(source: &str) -> Vec<Range<usize>> {
let mut out = Vec::new();
for (event, range) in Parser::new_ext(source, OPTIONS).into_offset_iter() {
match event {
Event::Code(_) | Event::Html(_) | Event::InlineHtml(_) => out.push(range),
Event::Start(Tag::CodeBlock(_)) => out.push(range),
Event::Start(Tag::Link { .. }) => {
let at = source[range.clone()]
.rfind("](")
.map_or(range.start, |ix| range.start + ix);
out.push(at..range.end);
}
Event::Start(Tag::Image { .. }) => out.push(range),
_ => {}
}
}
out
}
fn settle(text: &mut Text, marks: &Marks) {
if !text.text.contains(OPEN) {
return;
}
let mut settled = String::with_capacity(text.text.len());
let mut cut: Vec<(usize, usize)> = Vec::new();
let mut open: Vec<(usize, usize, usize)> = Vec::new();
let mut found: Vec<(usize, MarkSpan)> = Vec::new();
let mut opened = 0usize;
let mut chars = text.text.char_indices();
while let Some((at, c)) = chars.next() {
match c {
OPEN => {
let width = match chars.next() {
Some((_, tag)) => {
if let Some(ix) = tag_index(tag) {
open.push((ix, settled.len(), opened));
opened += 1;
}
OPEN.len_utf8() + tag.len_utf8()
}
None => OPEN.len_utf8(),
};
cut.push((at, width));
}
CLOSE => {
if let Some((ix, from, seq)) = open.pop()
&& let Some(entry) = marks.index(ix)
{
found.push((
seq,
MarkSpan {
range: from..settled.len(),
mark: Mark::Custom(entry.name.to_string()),
},
));
}
cut.push((at, CLOSE.len_utf8()));
}
_ => settled.push(c),
}
}
let moved = |offset: usize| {
offset
- cut
.iter()
.filter(|(at, _)| *at < offset)
.map(|(_, width)| width)
.sum::<usize>()
};
for span in &mut text.marks {
span.range = moved(span.range.start)..moved(span.range.end);
}
text.text = settled;
found.sort_by_key(|(seq, _)| *seq);
text.marks.extend(found.into_iter().map(|(_, span)| span));
text.marks
.sort_by_key(|span| (span.range.start, std::cmp::Reverse(span.range.end)));
text.marks.retain(|span| !span.range.is_empty());
}