#[cfg(feature = "mdx")]
use satteri_arena::decode_string_ref_data;
use satteri_arena::{Arena, ArenaBuilder, Mdast, StringRef};
use satteri_ast::mdast::{MdastNodeType, codec::LinkData};
use crate::puncttable::is_punctuation;
#[cfg(feature = "mdx")]
pub(crate) const MDX_EXPLICIT_JSX_DATA: &[u8] = b"{\"_mdxExplicitJsx\":true}";
fn is_correct_domain_for_fnr(domain: &[u8]) -> bool {
let parts: Vec<&[u8]> = domain.split(|&b| b == b'.').collect();
if parts.len() < 2 {
return false;
}
let check = |p: &[u8]| -> bool {
if p.is_empty() {
return true;
}
if p.contains(&b'_') {
return false;
}
p.iter().any(|&b| b.is_ascii_alphanumeric())
};
check(parts[parts.len() - 1]) && check(parts[parts.len() - 2])
}
fn split_url_trim_end(bytes: &[u8], min_end: usize, raw_end: usize) -> usize {
let mut trail_start = raw_end;
while trail_start > min_end {
let b = bytes[trail_start - 1];
if matches!(
b,
b'!' | b'"'
| b'&'
| b'\''
| b')'
| b','
| b'.'
| b':'
| b';'
| b'<'
| b'>'
| b'?'
| b']'
| b'}'
) {
trail_start -= 1;
} else {
break;
}
}
if trail_start == raw_end {
return raw_end;
}
let mut url_end = trail_start;
let url_segment = &bytes[min_end..url_end];
let mut opens = url_segment.iter().filter(|&&c| c == b'(').count();
let mut closes = url_segment.iter().filter(|&&c| c == b')').count();
let trail = &bytes[trail_start..raw_end];
let mut trail_pos = 0usize;
while opens > closes {
let mut found = None;
for (i, &c) in trail[trail_pos..].iter().enumerate() {
if c == b')' {
found = Some(trail_pos + i);
break;
}
}
match found {
Some(p) => {
let consumed_end = p + 1;
let segment = &trail[trail_pos..consumed_end];
opens += segment.iter().filter(|&&c| c == b'(').count();
closes += segment.iter().filter(|&&c| c == b')').count();
url_end = trail_start + consumed_end;
trail_pos = consumed_end;
}
None => break,
}
}
url_end
}
#[inline]
pub(crate) fn match_autolink_scheme(bytes: &[u8], ix: usize) -> Option<(usize, bool)> {
let rest = bytes.get(ix..)?;
let &[a, b, c, d, ..] = rest else {
return None;
};
if a | 0x20 == b'w' && b | 0x20 == b'w' && c | 0x20 == b'w' && d == b'.' {
return Some((4, true));
}
if a | 0x20 != b'h' || b | 0x20 != b't' || c | 0x20 != b't' || d | 0x20 != b'p' {
return None;
}
if rest.len() >= 8 && rest[4] | 0x20 == b's' && rest[5..8] == *b"://" {
Some((8, false))
} else if rest.len() >= 7 && rest[4..7] == *b"://" {
Some((7, false))
} else {
None
}
}
fn trail_is_all(bytes: &[u8], mut i: usize, end: usize) -> bool {
while i < end {
match bytes[i] {
b'!' | b'"' | b'\'' | b')' | b'*' | b',' | b'.' | b':' | b';' | b'?' | b']' | b'_'
| b'~' => i += 1,
b'&' => {
let mut j = i + 1;
while j < end && bytes[j].is_ascii_alphabetic() {
j += 1;
}
if j > i + 1 && j < end && bytes[j] == b';' {
i = j + 1;
} else {
return false;
}
}
_ => return false,
}
}
true
}
fn construct_url_end(bytes: &[u8], start: usize, raw_end: usize) -> usize {
let (mut size_open, mut size_close) = (0usize, 0usize);
let mut i = start;
while i < raw_end {
let b = bytes[i];
if b == b'(' {
size_open += 1;
} else if b == b')' && size_close < size_open {
size_close += 1;
} else if matches!(
b,
b'!' | b'"'
| b'&'
| b'\''
| b')'
| b'*'
| b','
| b'.'
| b':'
| b';'
| b'?'
| b']'
| b'_'
| b'~'
) {
if trail_is_all(bytes, i, raw_end) {
return i;
}
if b == b')' {
size_close += 1;
}
}
i += 1;
}
raw_end
}
pub(crate) fn scan_autolink_literal(
bytes: &[u8],
ix: usize,
prev_is_content_start: bool,
) -> Option<(usize, usize, usize, String, bool)> {
let (proto_len, is_www) = match_autolink_scheme(bytes, ix)?;
let prev_loose_only = if ix > 0 && !prev_is_content_start {
let prev = bytes[ix - 1];
let prev_loose_ok = if is_www {
matches!(
prev,
b'(' | b'*' | b'_' | b'[' | b']' | b'~' | b' ' | b'\t' | b'\r' | b'\n'
)
} else if prev < 0x80 {
!prev.is_ascii_alphabetic()
} else {
true
};
if !prev_loose_ok {
return None;
}
!fnr_previous_ok(bytes, ix)
} else {
false
};
let construct_first_ok = if is_www {
true
} else {
let first = bytes.get(ix + proto_len).copied();
match first {
None => false,
Some(b) if b <= b' ' || b == 0x7F => false,
Some(b) if b < 0x80 && b.is_ascii_punctuation() => false,
_ => true,
}
};
let mut end = ix + proto_len;
while end < bytes.len() {
let b = bytes[end];
if !b.is_ascii_graphic() {
if b < 0x80 || char_at(bytes, end).is_some_and(is_autolink_whitespace) {
break;
}
} else if b == b'<' {
break;
}
if b == b']' {
let next = bytes.get(end + 1).copied();
if matches!(
next,
None | Some(b'(')
| Some(b'[')
| Some(b' ')
| Some(b'\t')
| Some(b'\n')
| Some(b'\r')
) {
break;
}
}
end += 1;
}
if end == ix + proto_len && !(is_www && ix + proto_len < bytes.len()) {
return None;
}
let raw_end = end;
let scan_start = if is_www { ix + 3 } else { ix + proto_len };
end = construct_url_end(bytes, scan_start, raw_end);
if end <= if is_www { ix } else { ix + proto_len } {
return None;
}
let body = if is_www {
&bytes[ix..end]
} else {
&bytes[ix + proto_len..end]
};
let construct_domain_end = body
.iter()
.position(|&b| {
!(b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b'.' || b >= 0x80)
})
.unwrap_or(body.len());
let domain = &body[..construct_domain_end];
let construct_seen = domain
.iter()
.any(|&b| b.is_ascii_alphanumeric() || b == b'-' || b >= 0x80);
let construct_underscore_ok = {
let mut last_has_us = false;
let mut penult_has_us = false;
for &b in domain {
if b == b'_' {
last_has_us = true;
} else if b == b'.' {
penult_has_us = last_has_us;
last_has_us = false;
}
}
!last_has_us && !penult_has_us
};
let construct_ok = construct_first_ok && construct_seen && construct_underscore_ok;
if !construct_ok {
if prev_loose_only {
return None;
}
let fnr_body = &bytes[ix + proto_len..raw_end];
let fnr_domain_end = fnr_body
.iter()
.position(|&b| !(b == b'.' || b == b'_' || b == b'-' || b.is_ascii_alphanumeric()))
.unwrap_or(fnr_body.len());
let fnr_domain = &fnr_body[..fnr_domain_end];
if !is_correct_domain_for_fnr(fnr_domain) {
return None;
}
end = split_url_trim_end(bytes, ix + proto_len, raw_end);
if end <= ix + proto_len {
return None;
}
}
let url_str = core::str::from_utf8(&bytes[ix..end]).ok()?;
let full_url = if is_www {
format!("http://{url_str}")
} else {
url_str.to_string()
};
Some((ix, raw_end, end, full_url, !construct_ok))
}
#[inline]
fn is_email_local_char(b: u8) -> bool {
b.is_ascii_alphanumeric() || matches!(b, b'.' | b'+' | b'-' | b'_')
}
pub(crate) fn scan_email_autolink(
bytes: &[u8],
at_ix: usize,
dot_needs_alnum: bool,
) -> Option<(usize, usize, String, bool)> {
if at_ix >= bytes.len() || bytes[at_ix] != b'@' {
return None;
}
let mut start = at_ix;
while start > 0 && is_email_local_char(bytes[start - 1]) {
start -= 1;
}
if start == at_ix {
return None;
}
let max_prev = if start == 0 {
None
} else {
Some(bytes[start - 1])
};
let max_walkback_ok = match max_prev {
None => true,
Some(p) => p != b'/',
};
let mut retry_needed = !max_walkback_ok;
if !max_walkback_ok {
while start < at_ix {
let prev_ok = if start == 0 {
true
} else {
let p = bytes[start - 1];
p != b'/' && !p.is_ascii_alphanumeric()
};
if prev_ok {
break;
}
start += 1;
}
if start >= at_ix {
return None;
}
retry_needed = true;
}
if at_ix + 1 >= bytes.len() {
return None;
}
let mut end = at_ix + 1;
while end < bytes.len() {
let b = bytes[end];
let is_label = b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_');
let after_dot_ok = bytes.get(end + 1).is_some_and(|&n| {
n.is_ascii_alphanumeric() || (!dot_needs_alnum && matches!(n, b'-' | b'_'))
});
if is_label || (b == b'.' && after_dot_ok) {
end += 1;
} else {
break;
}
}
if end == at_ix + 1 {
return None;
}
{
let last = bytes[end - 1];
if matches!(last, b'-' | b'_') || last.is_ascii_digit() {
return None;
}
}
let domain = &bytes[at_ix + 1..end];
let last_dot = domain.iter().rposition(|&b| b == b'.')?;
let tld = &domain[last_dot + 1..];
if tld.is_empty() || !tld.iter().any(|&b| b.is_ascii_alphabetic()) {
return None;
}
let _ = tld;
let email_str = core::str::from_utf8(&bytes[start..end]).ok()?;
Some((start, end, format!("mailto:{email_str}"), retry_needed))
}
fn update_bracket_depth(was_open: bool, s: &str) -> bool {
let mut depth: i32 = if was_open { 1 } else { 0 };
let bytes = s.as_bytes();
let mut i = 0;
while i < bytes.len() {
let c = bytes[i];
if c == b'\\' {
i += 2;
continue;
}
match c {
b'[' => depth += 1,
b']' if depth > 0 => depth -= 1,
_ => {}
}
i += 1;
}
depth > 0
}
fn port_merge_autolinks(merged: &str, host_end: usize) -> bool {
let bytes = merged.as_bytes();
let Some(scheme_end) = merged[..host_end].rfind("://") else {
return false;
};
let mut scheme_start = scheme_end;
while scheme_start > 0 && bytes[scheme_start - 1].is_ascii_alphabetic() {
scheme_start -= 1;
}
scan_autolink_literal(bytes, scheme_start, false)
.is_some_and(|(_, _, url_end, _, _)| url_end > host_end)
}
pub(crate) fn merge_directive_port_splits(arena: &mut Arena<Mdast>) {
let parent_ids: Vec<u32> = (0..arena.len() as u32)
.filter(|&id| {
let n = arena.get_node(id);
matches!(
MdastNodeType::from_u8(n.node_type),
Some(
MdastNodeType::Paragraph
| MdastNodeType::Heading
| MdastNodeType::Emphasis
| MdastNodeType::Strong
| MdastNodeType::Delete
| MdastNodeType::Superscript
| MdastNodeType::Subscript
| MdastNodeType::TableCell
)
)
})
.collect();
for parent_id in parent_ids {
let children = arena.get_children(parent_id).to_vec();
if children.len() < 2 {
continue;
}
let mut new_children: Vec<u32> = Vec::with_capacity(children.len());
let mut i = 0;
let mut unmatched_open_bracket = false;
while i < children.len() {
let text_id = children[i];
let text_node = arena.get_node(text_id);
let is_text = text_node.node_type == MdastNodeType::Text as u8;
if is_text {
let d = arena.get_type_data(text_id);
if !d.is_empty() {
let s = arena.get_str(StringRef::from_bytes(d));
unmatched_open_bracket = update_bracket_depth(unmatched_open_bracket, s);
}
}
if !is_text || i + 1 >= children.len() {
new_children.push(text_id);
i += 1;
continue;
}
if unmatched_open_bracket {
new_children.push(text_id);
i += 1;
continue;
}
let dir_id = children[i + 1];
let dir_node = arena.get_node(dir_id);
if dir_node.node_type != MdastNodeType::TextDirective as u8 {
new_children.push(text_id);
i += 1;
continue;
}
let dir_data = arena.get_type_data(dir_id);
if dir_data.len() < 8 {
new_children.push(text_id);
i += 1;
continue;
}
let dir_name_sr = StringRef::from_bytes(&dir_data[..8]);
let dir_name = arena.get_str(dir_name_sr).to_string();
if dir_name.is_empty() || !dir_name.bytes().all(|b| b.is_ascii_digit()) {
new_children.push(text_id);
i += 1;
continue;
}
let text_data = arena.get_type_data(text_id);
let text_sr = StringRef::from_bytes(text_data);
let text_val = arena.get_str(text_sr).to_string();
let looks_like_url_host = {
let after_ws = text_val
.rsplit(|c: char| c.is_whitespace())
.next()
.unwrap_or("");
after_ws.contains("://")
};
if !looks_like_url_host {
new_children.push(text_id);
i += 1;
continue;
}
let host_end = text_val.len();
let mut merged = text_val;
merged.push(':');
merged.push_str(&dir_name);
let mut consumed = 2; if i + 2 < children.len() {
let after_id = children[i + 2];
let after_node = arena.get_node(after_id);
if after_node.node_type == MdastNodeType::Text as u8 {
let after_data = arena.get_type_data(after_id);
let after_sr = StringRef::from_bytes(after_data);
let after_val = arena.get_str(after_sr);
merged.push_str(after_val);
consumed = 3;
}
}
if !port_merge_autolinks(&merged, host_end) {
new_children.push(text_id);
i += 1;
continue;
}
let merged_sr = arena.alloc_string(&merged);
let text_node_start = arena.get_node(text_id).start_offset;
let last_id = children[i + consumed - 1];
let last_node = arena.get_node(last_id);
let end_offset = last_node.end_offset;
let end_line = last_node.end_line;
let end_column = last_node.end_column;
let start_line = arena.get_node(text_id).start_line;
let start_column = arena.get_node(text_id).start_column;
arena.set_type_data(text_id, &merged_sr.as_bytes());
arena.set_position(
text_id,
text_node_start,
end_offset,
start_line,
start_column,
end_line,
end_column,
);
if consumed == 3 {
let tail_sr = StringRef::from_bytes(arena.get_type_data(children[i + 2]));
let tail = arena.get_str(tail_sr);
unmatched_open_bracket = update_bracket_depth(unmatched_open_bracket, tail);
}
new_children.push(text_id);
i += consumed;
}
if new_children.len() != children.len() {
arena.set_children(parent_id, &new_children);
}
}
}
pub(crate) fn gfm_autolink_literal_pass(
arena: &mut Arena<Mdast>,
source_bytes: &[u8],
options: crate::Options,
mut cursor: Option<&mut satteri_arena::LineIndexCursor<'_, '_>>,
) {
let len = arena.len() as u32;
let mut candidates: Vec<u32> = Vec::new();
let text_ty = MdastNodeType::Text as u8;
for id in 0..len {
let node = arena.get_node(id);
if node.node_type != text_ty {
continue;
}
let parent = node.parent;
if parent == u32::MAX || parent >= len {
continue;
}
let data = arena.get_type_data(id);
if data.is_empty() {
continue;
}
let sr = StringRef::from_bytes(data);
if !has_autolink_trigger(arena.get_str(sr).as_bytes()) {
continue;
}
let mut ancestor = parent;
let mut inside_ignored = false;
while ancestor != u32::MAX && ancestor < len {
if matches!(
MdastNodeType::from_u8(arena.get_node(ancestor).node_type),
Some(
MdastNodeType::Link
| MdastNodeType::LinkReference
| MdastNodeType::Image
| MdastNodeType::ImageReference
| MdastNodeType::InlineCode
| MdastNodeType::Code
| MdastNodeType::MdxjsEsm
| MdastNodeType::MdxFlowExpression
| MdastNodeType::MdxTextExpression
| MdastNodeType::Yaml
| MdastNodeType::Toml
)
) {
inside_ignored = true;
break;
}
ancestor = arena.get_node(ancestor).parent;
}
if !inside_ignored {
candidates.push(id);
}
}
let smart = Smart {
quotes: options.has_smart_quotes(),
dashes: options.has_smart_dashes(),
ellipses: options.has_smart_ellipses(),
};
for node_id in candidates {
split_text_with_autolinks_fnr(arena, node_id, source_bytes, cursor.as_deref_mut(), smart);
}
}
const MEMCHR_MIN_LEN: usize = 32;
#[inline]
fn has_autolink_trigger(bytes: &[u8]) -> bool {
let mut from = 0;
while let Some(off) = next_trigger_byte(&bytes[from..]) {
let at = from + off;
match bytes[at] {
b'@' => return true,
b':' => {
if matches!(bytes.get(at + 1..at + 3), Some([b'/', b'/'])) {
return true;
}
}
_ => {
if at >= 3 && match_autolink_scheme(bytes, at - 3).is_some() {
return true;
}
}
}
from = at + 1;
}
false
}
#[inline]
fn next_trigger_byte(hay: &[u8]) -> Option<usize> {
if hay.len() < MEMCHR_MIN_LEN {
hay.iter().position(|&b| matches!(b, b'@' | b':' | b'.'))
} else {
memchr::memchr3(b'@', b':', b'.', hay)
}
}
#[inline]
fn next_autolink_trigger(bytes: &[u8], from: usize, upper: bool) -> Option<usize> {
let hay = &bytes[from..];
if hay.len() < MEMCHR_MIN_LEN {
return hay
.iter()
.position(|&b| matches!(b, b'h' | b'H' | b'w' | b'W' | b'@'))
.map(|i| from + i);
}
let lower = memchr::memchr3(b'h', b'w', b'@', hay);
if !upper {
return lower.map(|i| from + i);
}
let bound = lower.unwrap_or(hay.len());
memchr::memchr2(b'H', b'W', &hay[..bound])
.or(lower)
.map(|i| from + i)
}
#[inline]
fn is_autolink_whitespace(c: char) -> bool {
(c.is_whitespace() && c != '\u{85}') || c == '\u{FEFF}'
}
fn char_at(bytes: &[u8], ix: usize) -> Option<char> {
let rest = bytes.get(ix..)?;
let width = match *rest.first()? {
b if b < 0x80 => 1,
b if b >> 5 == 0b110 => 2,
b if b >> 4 == 0b1110 => 3,
_ => 4,
};
core::str::from_utf8(rest.get(..width)?)
.ok()?
.chars()
.next()
}
fn preceding_char(bytes: &[u8], ix: usize) -> Option<char> {
if ix == 0 {
return None;
}
let prev = bytes[ix - 1];
if prev < 0x80 {
return Some(prev as char);
}
let mut start = ix - 1;
while start > 0 && bytes[start] & 0xC0 == 0x80 {
start -= 1;
}
core::str::from_utf8(&bytes[start..ix])
.ok()?
.chars()
.next_back()
}
pub(crate) fn fnr_previous_ok(bytes: &[u8], ix: usize) -> bool {
match preceding_char(bytes, ix) {
None => true,
Some(c) => is_autolink_whitespace(c) || is_punctuation(c),
}
}
fn fnr_find_url(bytes: &[u8], ix: usize) -> Option<(usize, usize, String, usize)> {
let (proto_len, is_www) = match_autolink_scheme(bytes, ix)?;
let s = ix;
if !fnr_previous_ok(bytes, s) {
return None;
}
let domain_start = if is_www { s + 3 } else { s + proto_len };
let mut p = domain_start;
while p < bytes.len() {
let b = bytes[p];
if b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b'_') {
p += 1;
} else {
break;
}
}
let domain_end = p;
if domain_end == domain_start {
return None;
}
while p < bytes.len() {
if matches!(bytes[p], b' ' | b'\t' | b'\r' | b'\n') {
break;
}
p += 1;
}
let raw_end = p;
let domain_check_start = if is_www { s } else { domain_start };
if !is_correct_domain_for_fnr(&bytes[domain_check_start..domain_end]) {
return None;
}
let url_end = split_url_trim_end(bytes, domain_start, raw_end);
let min_nonempty = if is_www { s } else { domain_start };
if url_end <= min_nonempty {
return None;
}
let url_str = core::str::from_utf8(&bytes[s..url_end]).ok()?;
let full_url = if is_www {
format!("http://{url_str}")
} else {
url_str.to_string()
};
Some((s, url_end, full_url, raw_end))
}
fn fnr_find_email(bytes: &[u8], ix: usize) -> Option<(usize, usize, String, usize)> {
let (mut s, e, _url, _retry) = scan_email_autolink(bytes, ix, false)?;
let first_domain = *bytes.get(ix + 1)?;
if !(first_domain.is_ascii_alphanumeric() || first_domain == b'-' || first_domain == b'_') {
return None;
}
while s < ix && !fnr_previous_ok(bytes, s) {
s += 1;
}
if s >= ix {
return None;
}
let addr = core::str::from_utf8(&bytes[s..e]).ok()?;
Some((s, e, format!("mailto:{addr}"), e))
}
#[derive(Clone, Copy)]
struct Seg {
d_start: u32,
d_len: u32,
r_start: u32,
r_len: u32,
}
impl Seg {
#[inline]
fn is_one_to_one(&self) -> bool {
self.d_len == self.r_len && self.d_len > 0
}
}
enum RawMap {
Identity {
r_start: u32,
},
Segments(Vec<Seg>),
}
impl RawMap {
fn seg_containing(segs: &[Seg], d: usize) -> Option<&Seg> {
segs.iter()
.find(|s| (s.d_start as usize) <= d && d < (s.d_start + s.d_len) as usize)
}
fn raw_start_of(&self, d: usize) -> Option<usize> {
match self {
RawMap::Identity { r_start } => Some(*r_start as usize + d),
RawMap::Segments(segs) => match Self::seg_containing(segs, d) {
Some(s) if s.is_one_to_one() => {
Some((s.r_start as usize) + (d - s.d_start as usize))
}
Some(s) if d == s.d_start as usize => Some(s.r_start as usize),
Some(_) => None,
None => segs.last().map(|s| (s.r_start + s.r_len) as usize),
},
}
}
fn raw_end_of(&self, d: usize) -> Option<usize> {
match self {
RawMap::Identity { r_start } => Some(*r_start as usize + d),
RawMap::Segments(segs) => {
let Some(prev) = d.checked_sub(1) else {
return segs.first().map(|s| s.r_start as usize);
};
match Self::seg_containing(segs, prev) {
Some(s) if s.is_one_to_one() => {
Some((s.r_start as usize) + (d - s.d_start as usize))
}
Some(s) if d == (s.d_start + s.d_len) as usize => {
Some((s.r_start + s.r_len) as usize)
}
_ => None,
}
}
}
}
}
#[derive(Clone, Copy)]
pub(crate) struct Smart {
pub quotes: bool,
pub dashes: bool,
pub ellipses: bool,
}
fn smart_dash_counts(count: usize) -> (usize, usize) {
debug_assert!(count >= 2, "a lone hyphen is not a dash run");
match count % 6 {
0 | 3 => (count / 3, 0),
2 | 4 => (0, count / 2),
1 => (count / 3 - 1, 2),
_ => (count / 3, 1),
}
}
pub(crate) fn smart_dash_run(count: usize) -> String {
let (ems, ens) = smart_dash_counts(count);
let mut buf = String::with_capacity(EM_DASH.len() * (ems + ens));
for _ in 0..ems {
buf.push_str(EM_DASH);
}
for _ in 0..ens {
buf.push_str(EN_DASH);
}
buf
}
const EM_DASH: &str = "\u{2014}";
const EN_DASH: &str = "\u{2013}";
fn smart_seg(raw: &[u8], r: usize, dec: &[u8], d: usize, smart: Smart) -> Option<(usize, usize)> {
const ELLIPSIS: &str = "\u{2026}";
match raw[r] {
b'.' if smart.ellipses
&& raw[r..].starts_with(b"...")
&& dec[d..].starts_with(ELLIPSIS.as_bytes()) =>
{
Some((3, ELLIPSIS.len()))
}
b'-' if smart.dashes => {
let count = 1 + crate::scanners::scan_ch_repeat(&raw[(r + 1)..], b'-');
if count < 2 {
return None;
}
let (ems, ens) = smart_dash_counts(count);
let mut rest = &dec[d..];
for (n, dash) in [(ems, EM_DASH), (ens, EN_DASH)] {
for _ in 0..n {
rest = rest.strip_prefix(dash.as_bytes())?;
}
}
Some((count, EM_DASH.len() * (ems + ens)))
}
c @ (b'"' | b'\'') if smart.quotes => {
let curly: [&str; 2] = if c == b'"' {
["\u{201c}", "\u{201d}"]
} else {
["\u{2018}", "\u{2019}"]
};
curly
.iter()
.find(|q| dec[d..].starts_with(q.as_bytes()))
.map(|q| (1, q.len()))
}
_ => None,
}
}
fn build_raw_map(
source: &[u8],
r_start: usize,
r_end: usize,
decoded: &str,
smart: Smart,
) -> Option<RawMap> {
if r_start > r_end || r_end > source.len() {
return None;
}
let raw = &source[r_start..r_end];
let dec = decoded.as_bytes();
if raw == dec {
return Some(RawMap::Identity {
r_start: r_start as u32,
});
}
let mut segs: Vec<Seg> = Vec::new();
let push_atomic = |segs: &mut Vec<Seg>, r: usize, r_len: usize, d: usize, d_len: usize| {
segs.push(Seg {
d_start: d as u32,
d_len: d_len as u32,
r_start: (r_start + r) as u32,
r_len: r_len as u32,
});
};
let extend_one_to_one = |segs: &mut Vec<Seg>, r: usize, d: usize| {
if let Some(last) = segs.last_mut()
&& last.is_one_to_one()
&& (last.d_start + last.d_len) as usize == d
&& (last.r_start + last.r_len) as usize == r_start + r
{
last.d_len += 1;
last.r_len += 1;
return;
}
segs.push(Seg {
d_start: d as u32,
d_len: 1,
r_start: (r_start + r) as u32,
r_len: 1,
});
};
let skip_block_prefix = |segs: &mut Vec<Seg>, r: &mut usize, d: usize| {
let prefix_start = *r;
while *r < raw.len()
&& matches!(raw[*r], b' ' | b'\t' | b'>')
&& dec.get(d) != Some(&raw[*r])
{
*r += 1;
}
if *r > prefix_start {
segs.push(Seg {
d_start: d as u32,
d_len: 0,
r_start: (r_start + prefix_start) as u32,
r_len: (*r - prefix_start) as u32,
});
}
};
let smart_any = smart.quotes || smart.dashes || smart.ellipses;
let mut r = 0usize;
let mut d = 0usize;
while r < raw.len() {
match raw[r] {
b'&' => {
let (len, value) = crate::scanners::scan_entity(&raw[r..]);
if let Some(value) = value
&& dec[d..].starts_with(value.as_bytes())
{
push_atomic(&mut segs, r, len, d, value.len());
r += len;
d += value.len();
continue;
}
}
b'\\' => {
if let Some(&next) = raw.get(r + 1)
&& next.is_ascii_punctuation()
&& dec.get(d) == Some(&next)
{
push_atomic(&mut segs, r, 2, d, 1);
r += 2;
d += 1;
continue;
}
}
b'\r' if raw.get(r + 1) == Some(&b'\n') && dec.get(d) == Some(&b'\n') => {
push_atomic(&mut segs, r, 2, d, 1);
r += 2;
d += 1;
skip_block_prefix(&mut segs, &mut r, d);
continue;
}
b'\n' | b'\r' if dec.get(d) == Some(&raw[r]) => {
extend_one_to_one(&mut segs, r, d);
r += 1;
d += 1;
skip_block_prefix(&mut segs, &mut r, d);
continue;
}
b' ' | b'\t' if dec.get(d) != Some(&raw[r]) => {
let run_start = r;
while r < raw.len() && matches!(raw[r], b' ' | b'\t') {
r += 1;
}
if !matches!(raw.get(r), Some(b'\n') | Some(b'\r')) {
return None;
}
segs.push(Seg {
d_start: d as u32,
d_len: 0,
r_start: (r_start + run_start) as u32,
r_len: (r - run_start) as u32,
});
continue;
}
b'.' | b'-' | b'"' | b'\'' if smart_any => {
if let Some((r_len, d_len)) = smart_seg(raw, r, dec, d, smart) {
push_atomic(&mut segs, r, r_len, d, d_len);
r += r_len;
d += d_len;
continue;
}
}
0 => {
const REPLACEMENT: &str = "\u{FFFD}";
if dec[d..].starts_with(REPLACEMENT.as_bytes()) {
push_atomic(&mut segs, r, 1, d, REPLACEMENT.len());
r += 1;
d += REPLACEMENT.len();
continue;
}
}
_ => {}
}
if dec.get(d) != Some(&raw[r]) {
return None;
}
extend_one_to_one(&mut segs, r, d);
r += 1;
d += 1;
}
if d != dec.len() {
return None;
}
Some(RawMap::Segments(segs))
}
fn pos_for(
map: &RawMap,
cursor: &mut satteri_arena::LineIndexCursor<'_, '_>,
d_lo: usize,
d_hi: usize,
) -> Option<(u32, u32, u32, u32, u32, u32)> {
let so = map.raw_start_of(d_lo)? as u32;
let eo = map.raw_end_of(d_hi)? as u32;
if eo < so {
return None;
}
let (sl, sc) = cursor.offset_to_line_col(so);
let (el, ec) = cursor.offset_to_line_col(eo);
Some((so, eo, sl, sc, el, ec))
}
fn push_fnr_emails(
bytes: &[u8],
gap_start: usize,
gap_end: usize,
triggers: &[usize],
next_trigger: &mut usize,
out: &mut Vec<(usize, usize, usize, String)>,
) {
let mut last_end = gap_start;
while let Some(&at) = triggers.get(*next_trigger) {
if at >= gap_end {
return;
}
*next_trigger += 1;
if let Some((s, end, url, raw_end)) = fnr_find_email(&bytes[..gap_end], at)
&& s >= last_end
{
out.push((s, end, raw_end, url));
last_end = raw_end;
}
}
}
fn split_text_with_autolinks_fnr(
arena: &mut Arena<Mdast>,
text_id: u32,
source_bytes: &[u8],
cursor: Option<&mut satteri_arena::LineIndexCursor<'_, '_>>,
smart: Smart,
) {
let data = arena.get_type_data(text_id);
if data.is_empty() {
return;
}
let sr = StringRef::from_bytes(data);
let borrowed_text = arena.get_str(sr);
let bytes = borrowed_text.as_bytes();
let mut url_matches: Vec<(usize, usize, usize, String)> = Vec::new();
let mut email_triggers: Vec<usize> = Vec::new();
let upper = memchr::memchr2(b'H', b'W', bytes).is_some();
let mut i = 0;
while let Some(at) = next_autolink_trigger(bytes, i, upper) {
if bytes[at] == b'@' {
email_triggers.push(at);
} else if match_autolink_scheme(bytes, at).is_some()
&& let Some((s, url_end, url, raw_end)) = fnr_find_url(bytes, at)
{
url_matches.push((s, url_end, raw_end, url));
i = raw_end;
continue;
}
i = at + 1;
}
let mut matches: Vec<(usize, usize, usize, String)> =
Vec::with_capacity(url_matches.len() + email_triggers.len());
let mut trigger = 0;
let mut gap_start = 0;
for url_match in url_matches {
push_fnr_emails(
bytes,
gap_start,
url_match.0,
&email_triggers,
&mut trigger,
&mut matches,
);
gap_start = url_match.2;
matches.push(url_match);
}
push_fnr_emails(
bytes,
gap_start,
bytes.len(),
&email_triggers,
&mut trigger,
&mut matches,
);
if matches.is_empty() {
return;
}
let text = borrowed_text.to_string();
let bytes = text.as_bytes();
let node = arena.get_node(text_id);
let (r_start, r_end) = (node.start_offset as usize, node.end_offset as usize);
let map = cursor
.as_ref()
.and_then(|_| build_raw_map(source_bytes, r_start, r_end, &text, smart));
if cursor.is_some() && map.is_none() {
debug_assert!(
false,
"build_raw_map failed to reconstruct a text value from its source span"
);
}
let mut cursor = cursor;
let mut pos_for = |lo: usize, hi: usize| -> Option<(u32, u32, u32, u32, u32, u32)> {
let map = map.as_ref()?;
pos_for(map, cursor.as_deref_mut()?, lo, hi)
};
let mut new_children: Vec<u32> = Vec::new();
let mut cursor = 0usize;
for (s, url_end, raw_end, url) in matches {
if s > cursor {
let chunk = &text[cursor..s];
let new_text_id = arena.alloc_node(MdastNodeType::Text as u8);
let chunk_sr = arena.alloc_string(chunk);
arena.set_type_data(new_text_id, &chunk_sr.as_bytes());
if let Some((so, eo, sl, sc, el, ec)) = pos_for(cursor, s) {
arena.set_position(new_text_id, so, eo, sl, sc, el, ec);
}
new_children.push(new_text_id);
}
let link_id = arena.alloc_node(MdastNodeType::Link as u8);
let url_sr = arena.alloc_string(&url);
let link_data = LinkData {
url: url_sr,
title: StringRef::empty(),
};
arena.set_type_data(link_id, &link_data.to_bytes());
let link_text_id = arena.alloc_node(MdastNodeType::Text as u8);
let disp_sr = arena.alloc_string(&text[s..url_end]);
arena.set_type_data(link_text_id, &disp_sr.as_bytes());
if let Some((so, eo, sl, sc, el, ec)) = pos_for(s, url_end) {
arena.set_position(link_id, so, eo, sl, sc, el, ec);
arena.set_position(link_text_id, so, eo, sl, sc, el, ec);
}
arena.set_children(link_id, &[link_text_id]);
new_children.push(link_id);
if raw_end > url_end {
let trail_chunk = &text[url_end..raw_end];
let trail_id = arena.alloc_node(MdastNodeType::Text as u8);
let trail_sr = arena.alloc_string(trail_chunk);
arena.set_type_data(trail_id, &trail_sr.as_bytes());
if let Some((so, eo, sl, sc, el, ec)) = pos_for(url_end, raw_end) {
arena.set_position(trail_id, so, eo, sl, sc, el, ec);
}
new_children.push(trail_id);
}
cursor = raw_end;
}
if cursor < bytes.len() {
let chunk = &text[cursor..];
let new_text_id = arena.alloc_node(MdastNodeType::Text as u8);
let chunk_sr = arena.alloc_string(chunk);
arena.set_type_data(new_text_id, &chunk_sr.as_bytes());
if let Some((so, eo, sl, sc, el, ec)) = pos_for(cursor, bytes.len()) {
arena.set_position(new_text_id, so, eo, sl, sc, el, ec);
}
new_children.push(new_text_id);
}
arena.replace_node_with_children(text_id, &new_children);
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn emit_text_merging(
builder: &mut ArenaBuilder<Mdast>,
text_value: &str,
start: u32,
end: u32,
start_line: u32,
start_col: u32,
end_line: u32,
end_col: u32,
) {
if let Some(pid) = builder.last_sibling_id() {
let prev = builder.arena_ref().get_node(pid);
if prev.node_type == MdastNodeType::Text as u8 {
let prev_data = builder.arena_ref().get_type_data(pid);
if prev_data.len() >= 8 {
let prev_sr = StringRef::from_bytes(prev_data);
let prev_text = builder.arena_ref().get_str(prev_sr);
let combined = [prev_text, text_value].concat();
let new_sr = builder.alloc_string(&combined);
let pn = builder.arena_ref().get_node(pid);
builder.update_leaf_full(
pid,
pn.start_offset,
end,
pn.start_line,
pn.start_column,
end_line,
end_col,
&new_sr.as_bytes(),
);
return;
}
}
}
let sr = builder.alloc_string(text_value);
builder.add_leaf_full(
MdastNodeType::Text as u8,
start,
end,
start_line,
start_col,
end_line,
end_col,
&sr.as_bytes(),
);
}
#[cfg(feature = "mdx")]
pub(crate) fn mdx_mark_and_unravel(arena: &mut Arena<Mdast>) {
let len = arena.len() as u32;
let has_inline_mdx = (0..len).any(|id| {
matches!(
MdastNodeType::from_u8(arena.get_node(id).node_type),
Some(MdastNodeType::MdxJsxTextElement | MdastNodeType::MdxTextExpression),
)
});
if !has_inline_mdx {
return;
}
for id in 0..len {
let node = arena.get_node(id);
if node.node_type != MdastNodeType::Paragraph as u8 {
continue;
}
let children = arena.get_children(id).to_vec();
if children.is_empty() {
continue;
}
let mut all_mdx = true;
let mut has_mdx = false;
for &child_id in &children {
let child = arena.get_node(child_id);
match MdastNodeType::from_u8(child.node_type) {
Some(MdastNodeType::MdxJsxTextElement | MdastNodeType::MdxTextExpression) => {
has_mdx = true;
}
Some(MdastNodeType::Text) => {
let data = arena.get_type_data(child_id);
if !data.is_empty() {
let sr = decode_string_ref_data(data);
let text = arena.get_str(sr);
if !text.chars().all(|c| c.is_ascii_whitespace()) {
all_mdx = false;
break;
}
}
}
_ => {
all_mdx = false;
break;
}
}
}
if !all_mdx || !has_mdx {
continue;
}
let mut promoted: Vec<u32> = Vec::new();
for &child_id in &children {
let child = arena.get_node(child_id);
match MdastNodeType::from_u8(child.node_type) {
Some(MdastNodeType::MdxJsxTextElement) => {
arena.get_node_mut(child_id).node_type = MdastNodeType::MdxJsxFlowElement as u8;
promoted.push(child_id);
}
Some(MdastNodeType::MdxTextExpression) => {
arena.get_node_mut(child_id).node_type = MdastNodeType::MdxFlowExpression as u8;
promoted.push(child_id);
}
Some(MdastNodeType::Text) => {
let data = arena.get_type_data(child_id);
if !data.is_empty() {
let sr = decode_string_ref_data(data);
let text = arena.get_str(sr);
if !text.chars().all(|c| c.is_ascii_whitespace()) {
promoted.push(child_id);
}
}
}
_ => {
promoted.push(child_id);
}
}
}
arena.replace_node_with_children(id, &promoted);
}
}
#[cfg(test)]
mod tests {
use super::{RawMap, Smart, build_raw_map};
const OFF: Smart = Smart {
quotes: false,
dashes: false,
ellipses: false,
};
const ON: Smart = Smart {
quotes: true,
dashes: true,
ellipses: true,
};
fn map(source: &str, decoded: &str) -> RawMap {
build_raw_map(source.as_bytes(), 0, source.len(), decoded, OFF).expect("map should build")
}
fn smart_map(source: &str, decoded: &str) -> RawMap {
build_raw_map(source.as_bytes(), 0, source.len(), decoded, ON).expect("map should build")
}
#[test]
fn raw_map_identity_is_allocation_free() {
assert!(matches!(
map("www.x.y", "www.x.y"),
RawMap::Identity { r_start: 0 }
));
}
#[test]
fn raw_map_spans_a_character_reference_whole() {
let m = map("a&b", "a&b");
assert_eq!(m.raw_start_of(0), Some(0));
assert_eq!(m.raw_start_of(1), Some(1));
assert_eq!(m.raw_end_of(2), Some(6));
assert_eq!(m.raw_end_of(3), Some(7));
}
#[test]
fn raw_map_atomic_interior_has_no_position() {
let m = map("fj", "fj");
assert_eq!(m.raw_start_of(0), Some(0));
assert_eq!(m.raw_end_of(2), Some(7));
assert_eq!(m.raw_start_of(1), None);
assert_eq!(m.raw_end_of(1), None);
}
#[test]
fn raw_map_spans_a_smart_dash_run_whole() {
let m = smart_map("a--b", "a\u{2013}b");
assert_eq!(m.raw_start_of(1), Some(1));
assert_eq!(m.raw_end_of(4), Some(3));
assert_eq!(m.raw_start_of(2), None);
assert_eq!(m.raw_end_of(2), None);
}
#[test]
fn raw_map_spans_a_long_dash_run_by_the_shared_formula() {
let m = smart_map("a-----b", "a\u{2014}\u{2013}b");
assert_eq!(m.raw_start_of(1), Some(1));
assert_eq!(m.raw_end_of(7), Some(6));
let m = smart_map("a-------b", "a\u{2014}\u{2013}\u{2013}b");
assert_eq!(m.raw_start_of(1), Some(1));
assert_eq!(m.raw_end_of(10), Some(8));
}
#[test]
fn raw_map_spans_an_ellipsis_and_a_quote_whole() {
let m = smart_map("a...b", "a\u{2026}b");
assert_eq!(m.raw_start_of(1), Some(1));
assert_eq!(m.raw_end_of(4), Some(4));
let q = smart_map("a\"b\"", "a\u{201c}b\u{201d}");
assert_eq!(q.raw_start_of(1), Some(1));
assert_eq!(q.raw_end_of(4), Some(2));
}
#[test]
fn raw_map_leaves_smart_bytes_alone_when_the_option_is_off() {
assert!(matches!(map("a--b", "a--b"), RawMap::Identity { .. }));
assert!(build_raw_map("a--b".as_bytes(), 0, 4, "a\u{2013}b", OFF).is_none());
}
#[test]
fn raw_map_prefers_the_escape_over_a_smart_dash() {
let m = smart_map("\\---", "-\u{2013}");
assert_eq!(m.raw_end_of(1), Some(2));
assert_eq!(m.raw_end_of(4), Some(4));
}
#[test]
fn raw_map_excludes_a_continuation_prefix() {
let m = map("a\n> b", "a\nb");
assert_eq!(m.raw_end_of(2), Some(2));
assert_eq!(m.raw_start_of(2), Some(4));
}
#[test]
fn raw_map_handles_escapes_and_line_endings() {
let m = map("a\\_b\r\nc", "a_b\nc");
assert_eq!(m.raw_start_of(1), Some(1));
assert_eq!(m.raw_end_of(2), Some(3));
assert_eq!(m.raw_start_of(4), Some(6));
}
#[test]
fn raw_map_refuses_to_guess() {
assert!(build_raw_map(b"a&b", 0, 7, "a&z", OFF).is_none());
assert!(build_raw_map(b"abc", 0, 3, "abcd", OFF).is_none());
}
}