use std::ops::Range;
use gpui::{
Font, FontStyle, FontWeight, Hsla, SharedString, StrikethroughStyle, TextRun, UnderlineStyle,
hsla, px,
};
use crate::Diagnostic;
#[derive(Clone)]
pub struct SyntaxStyle {
pub marker: Hsla,
pub code: Hsla,
pub code_bg: Hsla,
pub link: Hsla,
pub tag: Hsla,
pub quote: Hsla,
pub alert_note: Hsla,
pub alert_tip: Hsla,
pub alert_important: Hsla,
pub alert_warning: Hsla,
pub alert_caution: Hsla,
pub alert_icons: Option<AlertIcons>,
pub rule: Hsla,
pub mark_bg: Hsla,
#[allow(clippy::type_complexity)]
pub block_label: Option<std::rc::Rc<dyn Fn(&str, &str) -> Option<String>>>,
pub block_label_gen: u64,
pub block_ref_count: Option<BlockRefCountFn>,
pub popover_bg: Hsla,
pub popover_border: Hsla,
pub popover_fg: Hsla,
pub popover_hover: Hsla,
pub popover_divider: Hsla,
pub popover_danger: Hsla,
pub mono: Font,
pub property_icon: Option<PropertyIconFn>,
}
impl Style {
fn over(mut self, base: &Style) -> Style {
self.bold |= base.bold;
self.italic |= base.italic;
self.strike |= base.strike;
self.underline |= base.underline;
self.mono |= base.mono;
if self.color.is_none() {
self.color = base.color;
}
if self.bg.is_none() {
self.bg = base.bg;
}
self
}
}
pub type PropertyIconFn = std::rc::Rc<dyn Fn(&str) -> Option<gpui::SharedString>>;
pub type BlockRefCountFn = std::rc::Rc<dyn Fn(&str) -> usize>;
#[derive(Clone, Default)]
struct Style {
bold: bool,
italic: bool,
strike: bool,
underline: bool,
mono: bool,
color: Option<Hsla>,
bg: Option<Hsla>,
hide: bool,
always_hide: bool,
pair_id: u16,
replace: Option<SharedString>,
}
struct Span {
range: Range<usize>,
style: Style,
}
pub(crate) fn styled_runs(
text: &str,
base_font: &Font,
base_color: Hsla,
diagnostics: &[Diagnostic],
md: Option<&SyntaxStyle>,
) -> Vec<TextRun> {
let spans = md.map(|s| scan(text, s)).unwrap_or_default();
let squiggle = UnderlineStyle {
color: Some(hsla(0., 0.8, 0.55, 1.)),
thickness: px(1.5),
wavy: true,
};
let mut bounds: Vec<usize> = vec![0, text.len()];
for s in &spans {
bounds.push(s.range.start);
bounds.push(s.range.end);
}
for d in diagnostics {
if d.range.start < d.range.end && d.range.end <= text.len() {
bounds.push(d.range.start);
bounds.push(d.range.end);
}
}
bounds.retain(|&b| b <= text.len());
bounds.sort_unstable();
bounds.dedup();
let mut runs = Vec::new();
for win in bounds.windows(2) {
let (a, b) = (win[0], win[1]);
if a >= b {
continue;
}
let style = spans
.iter()
.find(|s| s.range.start <= a && a < s.range.end)
.map(|s| &s.style);
let underline = diagnostics
.iter()
.any(|d| d.range.start <= a && a < d.range.end && d.range.end <= text.len());
let mut font = match style {
Some(s) if s.mono => md.map_or_else(|| base_font.clone(), |m| m.mono.clone()),
_ => base_font.clone(),
};
if let Some(s) = style {
if s.bold {
font.weight = FontWeight::BOLD;
}
if s.italic {
font.style = FontStyle::Italic;
}
}
runs.push(TextRun {
len: b - a,
font,
color: style.and_then(|s| s.color).unwrap_or(base_color),
background_color: style.and_then(|s| s.bg),
underline: if underline {
Some(squiggle)
} else {
style.filter(|s| s.underline).map(|_| UnderlineStyle {
thickness: px(1.0),
color: None,
wavy: false,
})
},
strikethrough: style.filter(|s| s.strike).map(|_| StrikethroughStyle {
thickness: px(1.5),
color: None,
}),
});
}
runs
}
fn run_for(
len: usize,
style: Option<&Style>,
base_font: &Font,
base_color: Hsla,
md: Option<&SyntaxStyle>,
underline: Option<UnderlineStyle>,
) -> TextRun {
let mut font = match style {
Some(s) if s.mono => md.map_or_else(|| base_font.clone(), |m| m.mono.clone()),
_ => base_font.clone(),
};
if let Some(s) = style {
if s.bold {
font.weight = FontWeight::BOLD;
}
if s.italic {
font.style = FontStyle::Italic;
}
}
TextRun {
len,
font,
color: style.and_then(|s| s.color).unwrap_or(base_color),
background_color: style.and_then(|s| s.bg),
underline: underline.or_else(|| {
style.filter(|s| s.underline).map(|_| UnderlineStyle {
thickness: px(1.0),
color: None,
wavy: false,
})
}),
strikethrough: style.filter(|s| s.strike).map(|_| StrikethroughStyle {
thickness: px(1.5),
color: None,
}),
}
}
fn construct_at(spans: &[Span], c: usize) -> Range<usize> {
let mut i = 0;
while i < spans.len() {
let start = spans[i].range.start;
let mut end = spans[i].range.end;
let mut j = i + 1;
while j < spans.len() && spans[j].range.start == end {
end = spans[j].range.end;
j += 1;
}
if start <= c && c <= end {
return start..end;
}
i = j;
}
0..0
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn hidden_runs(
line: &str,
base_font: &Font,
base_color: Hsla,
diagnostics: &[Diagnostic],
caret_col: Option<usize>,
reveal_prefix: usize,
hide_prefix: usize,
reveal_inline: bool,
md: &SyntaxStyle,
) -> (String, Vec<TextRun>, Vec<usize>) {
let mut spans = scan(line, md);
spans.sort_by_key(|s| s.range.start);
let reveal = caret_col.map_or(0..0, |c| construct_at(&spans, c));
let mut segs: Vec<(Range<usize>, Option<&Style>, Option<SharedString>)> = Vec::new();
let mut pos = 0;
for span in &spans {
if span.range.start > pos {
segs.push((pos..span.range.start, None, None));
}
let in_construct = reveal.start <= span.range.start && span.range.end <= reveal.end;
let in_prefix = span.range.end <= reveal_prefix;
let force = span.range.end <= hide_prefix;
let hidden = span.style.hide
&& (span.style.always_hide || force || (!reveal_inline && !in_construct && !in_prefix));
if !hidden {
segs.push((span.range.clone(), Some(&span.style), None));
} else if let Some(rep) = span.style.replace.clone() {
segs.push((span.range.clone(), Some(&span.style), Some(rep)));
}
pos = span.range.end;
}
if pos < line.len() {
segs.push((pos..line.len(), None, None));
}
let squiggle = UnderlineStyle {
color: Some(hsla(0., 0.8, 0.55, 1.)),
thickness: px(1.5),
wavy: true,
};
let mut display = String::with_capacity(line.len());
let mut runs: Vec<TextRun> = Vec::new();
let mut map: Vec<usize> = Vec::with_capacity(line.len() + 1);
for (src, style, replace) in &segs {
if let Some(rep) = replace {
display.push_str(rep.as_ref());
map.extend(std::iter::repeat_n(src.start, rep.len()));
runs.push(run_for(
rep.len(),
*style,
base_font,
base_color,
Some(md),
None,
));
continue;
}
display.push_str(&line[src.clone()]);
map.extend(src.clone());
let mut edges = vec![src.start, src.end];
for d in diagnostics.iter().filter(|d| d.range.start < d.range.end) {
for e in [d.range.start, d.range.end] {
if e > src.start && e < src.end {
edges.push(e);
}
}
}
edges.sort_unstable();
edges.dedup();
for w in edges.windows(2) {
let (a, b) = (w[0], w[1]);
let under = diagnostics
.iter()
.any(|d| d.range.start <= a && a < d.range.end);
runs.push(run_for(
b - a,
*style,
base_font,
base_color,
Some(md),
under.then_some(squiggle),
));
}
}
map.push(line.len());
(display, runs, map)
}
fn scan(text: &str, st: &SyntaxStyle) -> Vec<Span> {
let mut out = Vec::new();
let bytes = text.as_bytes();
let mut line_start = 0;
for i in 0..=bytes.len() {
if i == bytes.len() || bytes[i] == b'\n' {
scan_line(text, line_start, i, st, &mut out);
line_start = i + 1;
}
}
out
}
pub(crate) fn fmt_marker_pairs(line: &str, st: &SyntaxStyle) -> Vec<(Range<usize>, Range<usize>)> {
let mut spans = scan(line, st);
spans.sort_by_key(|s| s.range.start);
let mut open: Vec<(u16, Range<usize>)> = Vec::new();
let mut pairs = Vec::new();
for sp in spans.into_iter().filter(|s| s.style.always_hide) {
let id = sp.style.pair_id;
if id == 0 {
continue;
}
match open.iter().position(|(oid, _)| *oid == id) {
Some(k) => {
let (_, o) = open.remove(k);
pairs.push((o, sp.range));
}
None => open.push((id, sp.range)),
}
}
pairs.sort_by_key(|(o, _)| o.start);
pairs
}
fn marker(out: &mut Vec<Span>, range: Range<usize>, color: Hsla) {
out.push(Span {
range,
style: Style {
color: Some(color),
hide: true,
..Default::default()
},
});
}
fn fmt_marker(out: &mut Vec<Span>, range: Range<usize>, color: Hsla, pair_id: u16) {
out.push(Span {
range,
style: Style {
color: Some(color),
hide: true,
always_hide: true,
pair_id,
..Default::default()
},
});
}
#[allow(clippy::too_many_arguments)]
fn scan_styled_body(
text: &str,
s: usize,
e: usize,
st: &SyntaxStyle,
out: &mut Vec<Span>,
style: Style,
depth: u8,
next_id: &mut u16,
) {
if s >= e {
return;
}
if depth >= 3 {
push(out, s..e, style);
return;
}
let before = out.len();
scan_inline(text, s, e, st, out, &style, depth + 1, next_id);
let mut covered: Vec<Range<usize>> = out[before..].iter().map(|sp| sp.range.clone()).collect();
covered.sort_by_key(|r| r.start);
let mut pos = s;
let mut fills: Vec<Range<usize>> = Vec::new();
for r in covered {
if r.start > pos {
fills.push(pos..r.start);
}
pos = pos.max(r.end);
}
if pos < e {
fills.push(pos..e);
}
for f in fills {
push(out, f, style.clone());
}
}
fn apply_heading(
text: &str,
from: usize,
end: usize,
st: &SyntaxStyle,
out: &mut Vec<Span>,
) -> bool {
let Some(level) = heading_level(&text[from..end]) else {
return false;
};
let b = text.as_bytes();
let mut marker_end = from + level as usize;
if marker_end < end && b[marker_end] == b' ' {
marker_end += 1;
}
marker(out, from..marker_end, st.marker);
if marker_end < end {
push(
out,
marker_end..end,
Style {
bold: true,
..Default::default()
},
);
}
true
}
fn scan_line(text: &str, start: usize, end: usize, st: &SyntaxStyle, out: &mut Vec<Span>) {
let b = text.as_bytes();
let end = match zorite_markdown::syntax::block_id(&text[start..end]) {
Some((at, id)) => {
let refs = st.block_ref_count.as_ref().map_or(0, |f| f(id));
if refs > 0 {
let badge = format!(" {}", zorite_markdown::syntax::superscript(refs));
out.push(Span {
range: start + at..end,
style: Style {
color: Some(st.tag),
hide: true,
replace: Some(SharedString::from(badge)),
..Default::default()
},
});
} else {
marker(out, start + at..end, st.marker);
}
start + at
}
None => end,
};
if apply_heading(text, start, end, st, out) {
return;
}
let mut i = start;
if b.get(start) == Some(&b'>') {
let mut p = start;
while p < end && b[p] == b'>' {
p += 1;
if p < end && b[p] == b' ' {
p += 1;
}
}
if let Some((_, mlen, _)) = alert_prefix(&text[p..end]) {
p += mlen;
}
marker(out, start..p, st.marker);
i = p;
} else if let Some(prefix_len) = task_prefix(&text[start..end])
.map(|(p, ..)| p)
.or_else(|| list_prefix(&text[start..end]).map(|(p, ..)| p))
{
marker(out, start..start + prefix_len, st.marker);
i = start + prefix_len;
if apply_heading(text, i, end, st, out) {
return;
}
}
let mut next_id = 1u16;
scan_inline(text, i, end, st, out, &Style::default(), 0, &mut next_id);
}
#[allow(clippy::too_many_arguments)]
fn scan_inline(
text: &str,
start: usize,
end: usize,
st: &SyntaxStyle,
out: &mut Vec<Span>,
base: &Style,
depth: u8,
next_id: &mut u16,
) {
let b = text.as_bytes();
let mut i = start;
while i < end {
let c = b[i];
if c == b'\\'
&& i + 1 < end
&& b[i + 1].is_ascii_punctuation()
&& !is_backslash_escaped(b, i)
{
marker(out, i..i + 1, st.marker);
i += 2;
continue;
}
if c == b'`' && !is_backslash_escaped(b, i) {
let mut n = 1;
while i + n < end && b[i + n] == b'`' {
n += 1;
}
let mut j = i + n;
let mut close = None;
while j < end {
if b[j] == b'`' {
let mut m = 1;
while j + m < end && b[j + m] == b'`' {
m += 1;
}
if m == n {
close = Some(j);
break;
}
j += m;
} else {
j += 1;
}
}
if let Some(close) = close {
let id = *next_id;
*next_id += 1;
fmt_marker(out, i..i + n, st.marker, id);
push(
out,
i + n..close,
Style {
color: Some(st.code),
bg: Some(st.code_bg),
..Default::default()
}
.over(base),
);
fmt_marker(out, close..close + n, st.marker, id);
i = close + n;
continue;
}
i += n;
continue;
}
if c == b'*'
&& !is_backslash_escaped(b, i)
&& i + 1 < end
&& b[i + 1] == b'*'
&& let Some(close) = find2_unescaped(b, i + 2, end, b'*', b'*')
{
let id = *next_id;
*next_id += 1;
fmt_marker(out, i..i + 2, st.marker, id);
scan_styled_body(
text,
i + 2,
close,
st,
out,
Style {
bold: true,
..Default::default()
}
.over(base),
depth,
next_id,
);
fmt_marker(out, close..close + 2, st.marker, id);
i = close + 2;
continue;
}
if c == b'*'
&& !is_backslash_escaped(b, i)
&& let Some(close) = find1_unescaped(b, i + 1, end, b'*')
&& close > i + 1
{
let id = *next_id;
*next_id += 1;
fmt_marker(out, i..i + 1, st.marker, id);
scan_styled_body(
text,
i + 1,
close,
st,
out,
Style {
italic: true,
..Default::default()
}
.over(base),
depth,
next_id,
);
fmt_marker(out, close..close + 1, st.marker, id);
i = close + 1;
continue;
}
if c == b'_' && (i == start || (!is_word_byte(b[i - 1]) && b[i - 1] != b'\\')) {
if i + 1 < end
&& b[i + 1] == b'_'
&& i + 2 < end
&& b[i + 2] != b' '
&& let Some(close) = find_underscore_close(b, i + 2, end, true)
{
let id = *next_id;
*next_id += 1;
fmt_marker(out, i..i + 2, st.marker, id);
scan_styled_body(
text,
i + 2,
close,
st,
out,
Style {
bold: true,
..Default::default()
}
.over(base),
depth,
next_id,
);
fmt_marker(out, close..close + 2, st.marker, id);
i = close + 2;
continue;
}
if i + 1 < end
&& b[i + 1] != b'_'
&& b[i + 1] != b' '
&& let Some(close) = find_underscore_close(b, i + 1, end, false)
{
let id = *next_id;
*next_id += 1;
fmt_marker(out, i..i + 1, st.marker, id);
scan_styled_body(
text,
i + 1,
close,
st,
out,
Style {
italic: true,
..Default::default()
}
.over(base),
depth,
next_id,
);
fmt_marker(out, close..close + 1, st.marker, id);
i = close + 1;
continue;
}
}
if c == b'~'
&& !is_backslash_escaped(b, i)
&& i + 1 < end
&& b[i + 1] == b'~'
&& let Some(close) = find2_unescaped(b, i + 2, end, b'~', b'~')
{
let id = *next_id;
*next_id += 1;
fmt_marker(out, i..i + 2, st.marker, id);
scan_styled_body(
text,
i + 2,
close,
st,
out,
Style {
strike: true,
..Default::default()
}
.over(base),
depth,
next_id,
);
fmt_marker(out, close..close + 2, st.marker, id);
i = close + 2;
continue;
}
if c == b'['
&& !is_backslash_escaped(b, i)
&& i + 1 < end
&& b[i + 1] == b'^'
&& let Some(rb) = find1(b, i + 2, end, b']')
&& rb > i + 2
{
push(
out,
i..i + 1,
Style {
color: Some(st.link),
..Default::default()
}
.over(base),
);
marker(out, i + 1..i + 2, st.marker); push(
out,
i + 2..rb + 1,
Style {
color: Some(st.link),
..Default::default()
}
.over(base),
);
i = rb + 1;
continue;
}
if c == b'('
&& !is_backslash_escaped(b, i)
&& i + 1 < end
&& b[i + 1] == b'('
&& let Some(close) = find2(b, i + 2, end, b')', b')')
{
let id = &text[i + 2..close];
let label = (!id.is_empty()
&& id
.bytes()
.all(|c| c.is_ascii_alphanumeric() || c == b'-' || c == b'_'))
.then(|| st.block_label.as_ref().and_then(|f| f("", id)))
.flatten();
if let Some(label) = label {
out.push(Span {
range: i..close + 2,
style: Style {
color: Some(st.link),
hide: true,
replace: Some(SharedString::from(label)),
..Default::default()
},
});
i = close + 2;
continue;
}
}
if c == b'['
&& !is_backslash_escaped(b, i)
&& i + 1 < end
&& b[i + 1] == b'['
&& let Some(close) = find2(b, i + 2, end, b']', b']')
{
marker(out, i..i + 2, st.marker);
let link = Style {
color: Some(st.link),
..Default::default()
}
.over(base);
let inner = &text[i + 2..close];
let target_end = inner.find('|').unwrap_or(inner.len());
let anchor = match inner[..target_end].find('#') {
Some(a) if !inner[..a].to_ascii_lowercase().ends_with(".pdf") => {
let alen = if inner[a + 1..target_end].starts_with('^') {
2
} else {
1
};
Some((a, alen))
}
_ => None,
};
let resolved = match anchor {
Some((a, 2)) if a > 0 && a + 2 < target_end && target_end == inner.len() => st
.block_label
.as_ref()
.and_then(|f| f(&inner[..a], &inner[a + 2..target_end])),
_ => None,
};
match (resolved, anchor) {
(Some(label), _) => {
out.push(Span {
range: i + 2..close,
style: Style {
color: Some(st.link),
hide: true,
replace: Some(SharedString::from(label)),
..Default::default()
},
});
}
(None, Some((a, alen))) if a > 0 && a + alen < target_end => {
push(out, i + 2..i + 2 + a, link.clone());
out.push(Span {
range: i + 2 + a..i + 2 + a + alen,
style: Style {
color: Some(st.marker),
hide: true,
replace: Some(" → ".into()),
..Default::default()
},
});
push(out, i + 2 + a + alen..close, link);
}
_ => push(out, i + 2..close, link),
}
marker(out, close..close + 2, st.marker);
i = close + 2;
continue;
}
if c == b'['
&& !is_backslash_escaped(b, i)
&& let Some(rb) = find1(b, i + 1, end, b']')
&& rb + 1 < end
&& b[rb + 1] == b'('
&& let Some(rp) = find1(b, rb + 2, end, b')')
{
let marker_start = if i > start && b[i - 1] == b'!' {
i - 1
} else {
i
};
marker(out, marker_start..i + 1, st.marker);
push(
out,
i + 1..rb,
Style {
color: Some(st.link),
..Default::default()
}
.over(base),
);
marker(out, rb..rp + 1, st.marker);
i = rp + 1;
continue;
}
if c == b'['
&& !is_backslash_escaped(b, i)
&& let Some(rb) = find1(b, i + 1, end, b']')
&& rb + 1 < end
&& b[rb + 1] == b'['
&& let Some(rb2) = find1(b, rb + 2, end, b']')
{
marker(out, i..i + 1, st.marker);
push(
out,
i + 1..rb,
Style {
color: Some(st.link),
..Default::default()
}
.over(base),
);
marker(out, rb..rb2 + 1, st.marker);
i = rb2 + 1;
continue;
}
if c == b'<'
&& b[i..end].starts_with(b"<mark>")
&& let Some(rel) = text[i + 6..end].find("</mark>")
{
let body = i + 6;
let close = body + rel;
let id = *next_id;
*next_id += 1;
fmt_marker(out, i..body, st.marker, id);
scan_styled_body(
text,
body,
close,
st,
out,
Style {
bg: Some(st.mark_bg),
..Default::default()
}
.over(base),
depth,
next_id,
);
fmt_marker(out, close..close + 7, st.marker, id);
i = close + 7;
continue;
}
if c == b'<'
&& b[i..end].starts_with(b"<u>")
&& let Some(rel) = text[i + 3..end].find("</u>")
{
let body = i + 3;
let close = body + rel;
let id = *next_id;
*next_id += 1;
fmt_marker(out, i..body, st.marker, id);
scan_styled_body(
text,
body,
close,
st,
out,
Style {
underline: true,
..Default::default()
}
.over(base),
depth,
next_id,
);
fmt_marker(out, close..close + 4, st.marker, id);
i = close + 4;
continue;
}
if (b[i..end].starts_with(b"http://") || b[i..end].starts_with(b"https://"))
&& (i == start || !is_word(b[i - 1]))
{
let j = i + zorite_markdown::syntax::url_end(&text[i..end], 0);
if j > i + 8 {
push(
out,
i..j,
Style {
color: Some(st.link),
..Default::default()
}
.over(base),
);
i = j;
continue;
}
}
if c == b'#' && (i == start || !is_word(b[i - 1])) {
let mut j = i + 1;
while j < end && is_tag(b[j]) {
j += 1;
}
if j > i + 1 {
push(
out,
i..j,
Style {
color: Some(st.tag),
..Default::default()
}
.over(base),
);
i = j;
continue;
}
}
i += 1;
}
}
fn push(out: &mut Vec<Span>, range: Range<usize>, style: Style) {
out.push(Span { range, style });
}
fn find1(b: &[u8], from: usize, end: usize, c: u8) -> Option<usize> {
(from..end).find(|&k| b[k] == c)
}
fn find1_unescaped(b: &[u8], from: usize, end: usize, c: u8) -> Option<usize> {
(from..end).find(|&k| b[k] == c && !is_backslash_escaped(b, k))
}
fn find2(b: &[u8], from: usize, end: usize, c1: u8, c2: u8) -> Option<usize> {
(from..end.saturating_sub(1)).find(|&k| b[k] == c1 && b[k + 1] == c2)
}
fn find2_unescaped(b: &[u8], from: usize, end: usize, c1: u8, c2: u8) -> Option<usize> {
(from..end.saturating_sub(1))
.find(|&k| b[k] == c1 && b[k + 1] == c2 && !is_backslash_escaped(b, k))
}
fn is_word_byte(x: u8) -> bool {
x.is_ascii_alphanumeric() || x >= 0x80
}
fn find_underscore_close(b: &[u8], from: usize, end: usize, double: bool) -> Option<usize> {
let width = if double { 2 } else { 1 };
let mut j = from + 1; while j + width <= end {
if b[j] == b'_'
&& (!double || b[j + 1] == b'_')
&& b[j - 1] != b' '
&& b[j - 1] != b'\\'
&& (j + width >= end || !is_word_byte(b[j + width]))
{
return Some(j);
}
j += 1;
}
None
}
pub(crate) use zorite_markdown::syntax::{LinkHit, link_at, links};
pub(crate) fn heading_level(line: &str) -> Option<u8> {
let b = line.as_bytes();
let mut n = 0;
while n < b.len() && b[n] == b'#' {
n += 1;
}
((1..=6).contains(&n) && (n == b.len() || b[n] == b' ')).then_some(n as u8)
}
pub(crate) fn line_heading_level(line: &str) -> Option<(u8, Option<usize>)> {
if let Some(l) = heading_level(line) {
return Some((l, None));
}
let p = list_prefix(line).map(|(p, ..)| p)?;
let l = heading_level(&line[p..])?;
Some((l, Some(line.len() - line.trim_start().len())))
}
pub(crate) fn heading_fold_regions(
content: &str,
folded: &std::collections::HashSet<String>,
) -> Vec<Range<usize>> {
if folded.is_empty() {
return Vec::new();
}
let lines: Vec<&str> = content.split('\n').collect();
let indent = |l: &str| l.len() - l.trim_start().len();
let mut out = Vec::new();
let mut in_fence = false;
let mut i = 0;
while i < lines.len() {
if lines[i].trim_start().starts_with("```") {
in_fence = !in_fence;
i += 1;
continue;
}
let level = if in_fence {
None
} else {
line_heading_level(lines[i])
};
match level {
Some((l, list_indent)) if folded.contains(lines[i].trim()) => {
let start = i;
i += 1;
while i < lines.len() {
if lines[i].trim_start().starts_with("```") {
in_fence = !in_fence;
} else if !in_fence {
let stop = match list_indent {
None => heading_level(lines[i]).is_some_and(|n| n <= l),
Some(ind) => !lines[i].trim().is_empty() && indent(lines[i]) <= ind,
};
if stop {
break;
}
}
i += 1;
}
out.push(start..i);
}
_ => i += 1,
}
}
out
}
pub(crate) fn thematic_break(line: &str) -> bool {
let mut chars = line.bytes().filter(|b| !b.is_ascii_whitespace());
let Some(first) = chars.next() else {
return false;
};
if !matches!(first, b'-' | b'*' | b'_') {
return false;
}
let mut count = 1;
for b in chars {
if b != first {
return false;
}
count += 1;
}
count >= 3
}
pub(crate) fn footnote_def(line: &str) -> Option<usize> {
let rest = line.strip_prefix("[^")?;
let close = rest.find(']')?;
(close > 0 && rest[close + 1..].starts_with(':')).then_some(2 + close + 2)
}
pub(crate) fn html_block(line: &str) -> bool {
let t = line.trim_start().as_bytes();
matches!(t, [b'<', rest, ..] if rest.is_ascii_alphabetic() || *rest == b'/' || *rest == b'!')
}
pub(crate) use zorite_markdown::syntax::{AlertKind, alert_prefix};
#[derive(Clone)]
pub struct AlertIcons {
pub note: SharedString,
pub tip: SharedString,
pub important: SharedString,
pub warning: SharedString,
pub caution: SharedString,
}
impl AlertIcons {
pub(crate) fn get(&self, kind: AlertKind) -> SharedString {
match kind {
AlertKind::Note => self.note.clone(),
AlertKind::Tip => self.tip.clone(),
AlertKind::Important => self.important.clone(),
AlertKind::Warning => self.warning.clone(),
AlertKind::Caution => self.caution.clone(),
}
}
}
impl SyntaxStyle {
pub(crate) fn alert_color(&self, kind: AlertKind) -> Hsla {
match kind {
AlertKind::Note => self.alert_note,
AlertKind::Tip => self.alert_tip,
AlertKind::Important => self.alert_important,
AlertKind::Warning => self.alert_warning,
AlertKind::Caution => self.alert_caution,
}
}
}
pub(crate) fn alert_kind(body: &str) -> Option<AlertKind> {
alert_prefix(body).map(|(kind, ..)| kind)
}
pub(crate) fn alert_fold_regions(content: &str) -> Vec<(Range<usize>, bool)> {
fn marker_fold(line: &str) -> Option<Option<bool>> {
let p = blockquote_prefix(line)?;
alert_prefix(&line[p..]).map(|(_, _, fold)| fold)
}
let lines: Vec<&str> = content.split('\n').collect();
let mut out = Vec::new();
let mut i = 0;
while i < lines.len() {
if let Some(Some(folded)) = marker_fold(lines[i]) {
let start = i;
i += 1;
while i < lines.len()
&& blockquote_prefix(lines[i]).is_some()
&& marker_fold(lines[i]).is_none()
{
i += 1;
}
out.push((start..i, folded));
} else {
i += 1;
}
}
out
}
pub(crate) fn blockquote_prefix(line: &str) -> Option<usize> {
let b = line.as_bytes();
if b.first() != Some(&b'>') {
return None;
}
let mut p = 0;
while p < b.len() && b[p] == b'>' {
p += 1;
if p < b.len() && b[p] == b' ' {
p += 1;
}
}
Some(p)
}
pub(crate) fn list_prefix(line: &str) -> Option<(usize, usize, bool, u32)> {
let b = line.as_bytes();
let mut i = 0;
while i < b.len() && (b[i] == b' ' || b[i] == b'\t') {
i += 1;
}
let indent = i;
if i < b.len() && matches!(b[i], b'-' | b'*' | b'+') && b.get(i + 1) == Some(&b' ') {
return Some((i + 2, indent, false, 0));
}
let ds = i;
while i < b.len() && b[i].is_ascii_digit() {
i += 1;
}
if i > ds && matches!(b.get(i), Some(b'.') | Some(b')')) && b.get(i + 1) == Some(&b' ') {
let num = line[ds..i].parse::<u32>().unwrap_or(1);
return Some((i + 2, indent, true, num));
}
None
}
pub(crate) fn ordered_numbers(lines: &[&str]) -> Vec<(u32, usize)> {
let mut out = vec![(0u32, 0usize); lines.len()];
let mut stack: Vec<(usize, bool, u32)> = Vec::new();
for (i, line) in lines.iter().enumerate() {
let Some((_, indent, ordered, _)) = list_prefix(line) else {
let ws = line
.bytes()
.take_while(|b| matches!(b, b' ' | b'\t'))
.count();
let hanging =
!line.trim().is_empty() && stack.last().is_some_and(|&(ind, ..)| ws > ind);
if !hanging {
stack.clear();
}
continue;
};
while stack.last().is_some_and(|&(ind, ..)| ind > indent) {
stack.pop();
}
let depth = stack.len();
match stack.last_mut() {
Some((ind, ord, next)) if *ind == indent => {
if *ord != ordered {
*ord = ordered;
*next = 1;
}
if ordered {
out[i] = (*next, depth - 1);
*next += 1;
}
}
_ => {
stack.push((indent, ordered, 2));
out[i] = (1, depth);
}
}
}
out
}
pub(crate) fn renumber_copy(content: &str, range: std::ops::Range<usize>) -> String {
let lines: Vec<&str> = content.split('\n').collect();
let nums = ordered_numbers(&lines);
let mut out = String::with_capacity(range.len());
let mut line_start = 0;
for (i, line) in lines.iter().enumerate() {
let line_end = line_start + line.len();
let seg_start = range.start.max(line_start);
let seg_end = range.end.min(line_end);
if seg_start <= seg_end && range.start <= line_end && range.end >= line_start {
let rel = (seg_start - line_start)..(seg_end - line_start);
let marker = list_prefix(line).filter(|&(_, _, ordered, _)| ordered);
match marker {
Some((plen, indent, _, _))
if rel.start == 0 && rel.end >= plen && nums[i].0 > 0 =>
{
out.push_str(&line[..indent]);
out.push_str(&nums[i].0.to_string());
let digits_end = indent
+ line.as_bytes()[indent..]
.iter()
.take_while(|b| b.is_ascii_digit())
.count();
out.push_str(&line[digits_end..rel.end]);
}
_ => out.push_str(&line[rel]),
}
}
if range.start <= line_end && line_end < range.end && i + 1 < lines.len() {
out.push('\n');
}
line_start = line_end + 1;
}
out
}
pub(crate) fn task_prefix(line: &str) -> Option<(usize, usize, bool)> {
let (list_len, indent, ..) = list_prefix(line)?;
let rest = &line.as_bytes()[list_len..];
if rest.len() >= 4
&& rest[0] == b'['
&& rest[2] == b']'
&& rest[3] == b' '
&& matches!(rest[1], b' ' | b'x' | b'X')
{
return Some((list_len + 4, indent, matches!(rest[1], b'x' | b'X')));
}
None
}
pub(crate) fn toggle_task_checkbox(line: &str) -> Option<String> {
let (prefix_len, _indent, checked) = task_prefix(line)?;
let box_byte = prefix_len - 3; let mut out = line.to_string();
out.replace_range(box_byte..box_byte + 1, if checked { " " } else { "x" });
Some(out)
}
pub(crate) fn image_line(line: &str) -> Option<(&str, Option<f32>)> {
let rest = line.trim().strip_prefix("?;
let after_alt = &rest[close_alt + 2..];
let close_src = after_alt.find(')')?;
let src = after_alt[..close_src].trim();
let tail = after_alt[close_src + 1..].trim();
let width = if tail.is_empty() {
None
} else {
let w = tail.strip_prefix("{width=")?.strip_suffix('}')?;
Some(
w.strip_suffix("px")
.unwrap_or(w)
.trim()
.parse::<f32>()
.ok()?,
)
};
(!src.is_empty()).then_some((src, width))
}
pub(crate) fn image_row(line: &str) -> Option<(&str, Option<f32>, usize)> {
if let Some((src, width)) = image_line(line) {
return Some((src, width, 0));
}
let (plen, ..) = list_prefix(line)?;
let (src, width) = image_line(&line[plen..])?;
Some((src, width, plen))
}
pub(crate) fn line_scale(line: &str) -> f32 {
let body = list_prefix(line).map_or(line, |(p, ..)| &line[p..]);
match heading_level(body) {
Some(1) => 1.8,
Some(2) => 1.5,
Some(3) => 1.3,
Some(4) => 1.15,
Some(5) => 1.05,
_ => 1.0,
}
}
pub(crate) fn mermaid_blocks(content: &str) -> Vec<(Range<usize>, String)> {
let lines: Vec<&str> = content.split('\n').collect();
let mut out = Vec::new();
let mut i = 0;
while i < lines.len() {
let t = lines[i].trim_start();
if t.starts_with("```") && t[3..].trim() == "mermaid" {
let start = i;
let mut j = i + 1;
while j < lines.len() && !lines[j].trim_start().starts_with("```") {
j += 1;
}
let source = lines[start + 1..j].join("\n");
let end = (j + 1).min(lines.len()); out.push((start..end, source));
i = end;
} else {
i += 1;
}
}
out
}
pub(crate) fn math_blocks(content: &str) -> Vec<(Range<usize>, String)> {
math_regions(content)
.into_iter()
.map(|r| (r.range, r.source))
.collect()
}
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub enum MathAlign {
Left,
#[default]
Center,
Right,
}
impl MathAlign {
fn from_name(name: &str) -> Option<Self> {
match name {
"left" => Some(Self::Left),
"center" => Some(Self::Center),
"right" => Some(Self::Right),
_ => None,
}
}
pub(crate) fn marker(self) -> Option<&'static str> {
match self {
Self::Center => None,
Self::Left => Some("<!-- math:left -->"),
Self::Right => Some("<!-- math:right -->"),
}
}
}
pub(crate) fn math_align_marker(line: &str) -> Option<MathAlign> {
let inner = line
.trim()
.strip_prefix("<!--")?
.strip_suffix("-->")?
.trim();
MathAlign::from_name(inner.strip_prefix("math:")?.trim())
}
#[derive(Clone)]
pub(crate) struct MathRegion {
pub range: Range<usize>,
pub source: String,
pub align: MathAlign,
pub marker_line: Option<usize>,
}
pub(crate) fn math_regions(content: &str) -> Vec<MathRegion> {
let lines: Vec<&str> = content.split('\n').collect();
let mut out = Vec::new();
let mut in_code = false;
let mut i = 0;
while i < lines.len() {
if lines[i].trim_start().starts_with("```") {
in_code = !in_code;
i += 1;
continue;
}
if in_code {
i += 1;
continue;
}
if lines[i].trim() == "$$" {
let start = i;
let mut j = i + 1;
while j < lines.len() && lines[j].trim() != "$$" {
j += 1;
}
let source = lines[start + 1..j].join("\n");
let end = (j + 1).min(lines.len()); let (align, marker_line) = match start
.checked_sub(1)
.map(|m| (m, math_align_marker(lines[m])))
{
Some((m, Some(a))) => (a, Some(m)),
_ => (MathAlign::default(), None),
};
out.push(MathRegion {
range: start..end,
source,
align,
marker_line,
});
i = end;
} else if let Some(inner) = one_line_math(lines[i]) {
let (align, marker_line) =
match i.checked_sub(1).map(|m| (m, math_align_marker(lines[m]))) {
Some((m, Some(a))) => (a, Some(m)),
_ => (MathAlign::default(), None),
};
out.push(MathRegion {
range: i..i + 1,
source: inner.to_string(),
align,
marker_line,
});
i += 1;
} else {
i += 1;
}
}
out
}
fn one_line_math(line: &str) -> Option<&str> {
let t = line.trim();
let inner = t.strip_prefix("$$")?.strip_suffix("$$")?;
let inner = inner.trim();
(!inner.is_empty() && !inner.contains("$$")).then_some(inner)
}
pub(crate) fn inline_image_spans(line: &str) -> Vec<(Range<usize>, Range<usize>)> {
let b = line.as_bytes();
let mut out = Vec::new();
let mut i = 0;
while i + 1 < b.len() {
if b[i] == b'!'
&& b[i + 1] == b'['
&& let Some(rb) = line[i + 2..].find(']')
&& line[i + 2 + rb + 1..].starts_with('(')
&& let Some(rp) = line[i + 2 + rb + 2..].find(')')
{
let src = (i + 2 + rb + 2)..(i + 2 + rb + 2 + rp);
out.push((i..(src.end + 1), src.clone()));
i = src.end + 1;
} else {
i += 1;
}
}
out
}
pub(crate) fn inline_math_latex<'a>(line: &'a str, span: &Range<usize>) -> &'a str {
let d = if line[span.clone()].starts_with("$$") {
2
} else {
1
};
&line[span.start + d..span.end - d]
}
pub(crate) fn inline_math_spans(line: &str) -> Vec<Range<usize>> {
let bytes = line.as_bytes();
let mut out = Vec::new();
let mut i = 0;
while i < bytes.len() {
if bytes[i] != b'$' || is_backslash_escaped(bytes, i) {
i += 1;
continue;
}
if bytes.get(i + 1) == Some(&b'$') {
let mut j = i + 2;
let mut close = None;
while j + 1 < bytes.len() {
if bytes[j] == b'$' && bytes[j + 1] == b'$' && !is_backslash_escaped(bytes, j) {
close = Some(j);
break;
}
j += 1;
}
match close {
Some(c) if line[i + 2..c].trim().is_empty() => {}
Some(c) => {
out.push(i..c + 2);
i = c + 2;
continue;
}
None => {}
}
i += 2;
continue;
}
match bytes.get(i + 1) {
None => {
i += 1;
continue;
}
Some(c) if c.is_ascii_whitespace() => {
i += 1;
continue;
}
_ => {}
}
let mut j = i + 1;
let mut close = None;
while j < bytes.len() {
if bytes[j] == b'$' && !is_backslash_escaped(bytes, j) {
let space_before = bytes[j - 1].is_ascii_whitespace();
let digit_after = bytes.get(j + 1).is_some_and(|c| c.is_ascii_digit());
if !space_before && !digit_after {
close = Some(j);
break;
}
}
j += 1;
}
match close {
Some(c) => {
out.push(i..c + 1);
i = c + 1;
}
None => i += 1,
}
}
out
}
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct InlineMathPlace {
pub display_off: usize,
pub len: usize,
pub source: Range<usize>,
}
fn clip_runs(runs: &[TextRun], lo: usize, hi: usize) -> Vec<TextRun> {
let mut out = Vec::new();
let mut off = 0;
for r in runs {
let (s, e) = (off, off + r.len);
off = e;
let (cs, ce) = (s.max(lo), e.min(hi));
if cs < ce {
out.push(TextRun {
len: ce - cs,
..r.clone()
});
}
}
out
}
pub(crate) fn splice_inline_math(
display: &str,
runs: &[TextRun],
map: &[usize],
formulas: &[(Range<usize>, usize)],
gap: &TextRun,
) -> (String, Vec<TextRun>, Vec<usize>, Vec<InlineMathPlace>) {
let mut nd = String::with_capacity(display.len());
let mut nm: Vec<usize> = Vec::with_capacity(map.len());
let mut nr: Vec<TextRun> = Vec::new();
let mut places = Vec::new();
let mut cursor = 0; for (src, n) in formulas {
let d0 = map
.iter()
.position(|&s| s >= src.start)
.unwrap_or(display.len());
let d1 = map
.iter()
.position(|&s| s >= src.end)
.unwrap_or(display.len());
if d0 < cursor {
continue; }
nd.push_str(&display[cursor..d0]);
nm.extend_from_slice(&map[cursor..d0]);
nr.extend(clip_runs(runs, cursor, d0));
places.push(InlineMathPlace {
display_off: nd.len(),
len: *n,
source: src.clone(),
});
let src_at = map.get(d0).copied().unwrap_or(src.start);
for _ in 0..*n {
nd.push(' ');
}
nm.extend(std::iter::repeat_n(src_at, *n));
nr.push(TextRun {
len: *n,
..gap.clone()
});
cursor = d1;
}
nd.push_str(&display[cursor..]);
nm.extend_from_slice(&map[cursor..display.len()]);
nr.extend(clip_runs(runs, cursor, display.len()));
nm.push(*map.last().unwrap_or(&0)); (nd, nr, nm, places)
}
#[derive(Clone, Copy, PartialEq, Debug)]
pub(crate) enum Align {
Left,
Center,
Right,
}
pub(crate) use zorite_markdown::syntax::TableStyle;
pub(crate) fn table_style_marker(line: &str) -> Option<TableStyle> {
zorite_markdown::syntax::table_style_marker(line)
}
#[derive(Clone)]
pub(crate) struct TableRegion {
pub lines: Range<usize>,
pub aligns: Vec<Align>,
pub style: TableStyle,
pub marker_line: Option<usize>,
pub col_widths_attr: Option<Vec<f32>>,
pub rtl: bool,
}
pub(crate) fn table_regions(content: &str) -> Vec<TableRegion> {
let lines: Vec<&str> = content.split('\n').collect();
let mut out = Vec::new();
let mut i = 0;
while i + 1 < lines.len() {
if is_table_row(lines[i])
&& let Some(aligns) = separator_aligns(lines[i + 1])
{
let start = i;
let mut end = i + 2; while end < lines.len()
&& is_table_row(lines[end])
&& separator_aligns(lines.get(end + 1).copied().unwrap_or("")).is_none()
{
end += 1;
}
let (style, marker_line) = match start
.checked_sub(1)
.map(|m| (m, table_style_marker(lines[m])))
{
Some((m, Some(s))) => (s, Some(m)),
_ => (TableStyle::Grid, None),
};
let col_widths_attr =
marker_line.and_then(|m| zorite_markdown::syntax::table_col_widths(lines[m]));
let rtl =
zorite_markdown::syntax::base_direction(&lines[start..end].join("\n")).is_rtl();
out.push(TableRegion {
lines: start..end,
aligns,
style,
marker_line,
col_widths_attr,
rtl,
});
i = end;
} else {
i += 1;
}
}
out
}
pub(crate) fn is_table_row(line: &str) -> bool {
line.trim_start().starts_with('|')
}
pub(crate) fn property_regions(content: &str) -> Vec<Range<usize>> {
let lines: Vec<&str> = content.split('\n').collect();
let mut out = Vec::new();
let mut in_fence = false;
let mut i = 0;
while i < lines.len() {
if lines[i].trim_start().starts_with("```") {
in_fence = !in_fence;
i += 1;
continue;
}
if !in_fence && zorite_markdown::syntax::prefixed_property(lines[i]).is_some() {
let start = i;
i += 1;
while i < lines.len()
&& !lines[i].trim_start().starts_with("```")
&& zorite_markdown::syntax::prefixed_property(lines[i]).is_some()
{
i += 1;
}
out.push(start..i);
} else {
i += 1;
}
}
out
}
pub(crate) fn table_cells(line: &str) -> Vec<&str> {
let t = line.trim();
let t = t.strip_prefix('|').unwrap_or(t);
let t = t.strip_suffix('|').unwrap_or(t);
split_unescaped_pipes(t).map(str::trim).collect()
}
fn split_unescaped_pipes(s: &str) -> impl Iterator<Item = &str> {
let b = s.as_bytes();
let mut cuts = vec![0usize];
for (i, &c) in b.iter().enumerate() {
if c == b'|' && !is_backslash_escaped(b, i) {
cuts.push(i + 1);
}
}
cuts.push(s.len() + 1);
(0..cuts.len() - 1).map(move |k| &s[cuts[k]..cuts[k + 1] - 1])
}
fn is_backslash_escaped(b: &[u8], i: usize) -> bool {
let mut n = 0;
while n < i && b[i - 1 - n] == b'\\' {
n += 1;
}
n % 2 == 1
}
pub(crate) fn table_cell_ranges(line: &str) -> Vec<Range<usize>> {
let (Some(first), Some(last)) = (line.find('|'), line.rfind('|')) else {
return Vec::new();
};
if last <= first {
return Vec::new();
}
let base = first + 1; let inner = &line[base..last];
let mut out = Vec::new();
let mut seg = 0; for piece in split_unescaped_pipes(inner) {
let lead = piece.len() - piece.trim_start().len();
let trail = piece.len() - piece.trim_end().len();
let start = base + seg + lead;
out.push(start..(base + seg + piece.len() - trail).max(start));
seg += piece.len() + 1; }
out
}
fn separator_aligns(line: &str) -> Option<Vec<Align>> {
if !is_table_row(line) {
return None;
}
let cells = table_cells(line);
if cells.is_empty() {
return None;
}
cells
.iter()
.map(|c| {
let left = c.starts_with(':');
let right = c.ends_with(':');
let dashes = c.trim_matches(':');
(!dashes.is_empty() && dashes.bytes().all(|b| b == b'-')).then_some(
match (left, right) {
(true, true) => Align::Center,
(false, true) => Align::Right,
_ => Align::Left,
},
)
})
.collect()
}
fn is_word(c: u8) -> bool {
c.is_ascii_alphanumeric() || c == b'_'
}
use zorite_markdown::syntax::is_tag_char as is_tag;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn copies_renumber_to_displayed_positions() {
let doc = "1. a\n7. b\n 3. c\n 9. d\n5. e";
assert_eq!(
renumber_copy(doc, 0..doc.len()),
"1. a\n2. b\n 1. c\n 2. d\n3. e"
);
let from = doc.find("7.").unwrap();
assert_eq!(
renumber_copy(doc, from..doc.len()),
"2. b\n 1. c\n 2. d\n3. e"
);
let from = doc.find("b").unwrap();
assert_eq!(
renumber_copy(doc, from..doc.find('c').unwrap() + 1),
"b\n 1. c"
);
assert_eq!(renumber_copy("plain\ntext", 0..10), "plain\ntext");
}
#[test]
fn selection_reveal_shows_inline_but_not_prefix() {
let (font, c, st) = (Font::default(), Hsla::default(), test_style());
let (disp, _, _) = hidden_runs("- a **b** c", &font, c, &[], None, 0, 2, true, &st);
assert_eq!(disp, "a b c");
let (disp, _, _) = hidden_runs("- [t](u) c", &font, c, &[], None, 0, 2, true, &st);
assert_eq!(disp, "[t](u) c");
let (disp, _, _) = hidden_runs("- a **b** c", &font, c, &[], None, 0, 2, false, &st);
assert_eq!(disp, "a b c");
}
fn emphasis_spans(line: &str) -> Vec<(String, bool, bool)> {
let st = test_style();
let mut out = Vec::new();
scan_line(line, 0, line.len(), &st, &mut out);
out.iter()
.filter(|sp| sp.style.bold || sp.style.italic)
.map(|sp| {
(
line[sp.range.clone()].to_string(),
sp.style.bold,
sp.style.italic,
)
})
.collect()
}
#[test]
fn underscore_emphasis_at_word_boundaries() {
assert_eq!(
emphasis_spans("_italic_ and __bold__"),
vec![
("italic".to_string(), false, true),
("bold".to_string(), true, false)
]
);
assert_eq!(
emphasis_spans("mid _span here_ end"),
vec![("span here".to_string(), false, true)]
);
}
#[test]
fn underscores_inside_words_stay_plain() {
assert_eq!(emphasis_spans("snake_case_name"), vec![]);
assert_eq!(emphasis_spans("a foo_bar_baz b"), vec![]);
assert_eq!(emphasis_spans("CONST_MAX_VALUE = 3"), vec![]);
assert_eq!(emphasis_spans("_a_b"), vec![]);
assert_eq!(emphasis_spans("_ nope_"), vec![]);
assert_eq!(emphasis_spans("_nope _"), vec![]);
}
#[test]
fn escaped_underscores_stay_literal() {
assert_eq!(emphasis_spans(r"¯\_(ツ)_/¯"), vec![]);
assert_eq!(emphasis_spans(r"\_not italic\_"), vec![]);
}
#[test]
fn underline_tags_hide_and_style_the_body() {
let text = "an <u>underlined</u> word";
let st = test_style();
let mut out = Vec::new();
scan_line(text, 0, text.len(), &st, &mut out);
let open = text.find("<u>").unwrap();
let body = open + 3;
assert!(out.iter().any(|s| s.range == (open..body) && s.style.hide));
assert!(
out.iter()
.any(|s| s.range.start == body && s.style.underline)
);
}
#[test]
fn scan_line_survives_multibyte_text() {
let text = "- I don't know what is wrong ¯\\_(ツ)_/¯ https://a.io";
let st = test_style();
let mut out = Vec::new();
scan_line(text, 0, text.len(), &st, &mut out);
assert!(
out.iter()
.any(|s| s.range.start == text.find("https").unwrap())
);
}
fn spans(line: &str) -> Vec<&str> {
inline_math_spans(line)
.into_iter()
.map(|r| &line[r])
.collect()
}
#[test]
fn inline_math_basic() {
assert_eq!(spans("the area $\\pi r^2$ of a circle"), vec!["$\\pi r^2$"]);
assert_eq!(spans("$x$"), vec!["$x$"]);
assert_eq!(spans("a $x$ and $y$ b"), vec!["$x$", "$y$"]);
assert_eq!(spans("$x = 5$ done"), vec!["$x = 5$"]);
}
#[test]
fn inline_math_rejects_money_and_empties() {
assert!(spans("it cost $5 and $10 total").is_empty());
assert!(spans("$ x $").is_empty()); assert!(spans("a $$ b").is_empty()); assert!(spans("lone $ dollar").is_empty());
assert!(spans("$x$5").is_empty());
}
#[test]
fn inline_math_escapes_and_later_close() {
assert!(spans("price is 5\\$ even").is_empty());
assert_eq!(spans("\\$ and $x$"), vec!["$x$"]);
assert_eq!(spans("$a $5 b$ end"), vec!["$a $5 b$"]);
}
fn run(len: usize) -> TextRun {
TextRun {
len,
font: gpui::font("Helvetica"),
color: Hsla::default(),
background_color: None,
underline: None,
strikethrough: None,
}
}
fn runs_len(runs: &[TextRun]) -> usize {
runs.iter().map(|r| r.len).sum()
}
#[test]
fn splice_one_formula() {
let display = "a $x$ b";
let map: Vec<usize> = (0..=display.len()).collect();
let (nd, nr, nm, places) =
splice_inline_math(display, &[run(display.len())], &map, &[(2..5, 3)], &run(0));
assert_eq!(nd, "a b"); assert_eq!(
places,
vec![InlineMathPlace {
display_off: 2,
len: 3,
source: 2..5
}]
);
assert_eq!(nm, vec![0, 1, 2, 2, 2, 5, 6, 7]); assert_eq!(nm.len(), nd.len() + 1);
assert_eq!(runs_len(&nr), nd.len()); }
#[test]
fn splice_two_formulas_and_leading() {
let display = "$a$ and $b$";
let map: Vec<usize> = (0..=display.len()).collect();
let (nd, nr, nm, places) = splice_inline_math(
display,
&[run(display.len())],
&map,
&[(0..3, 2), (8..11, 4)],
&run(0),
);
assert_eq!(nd, " and "); assert_eq!(
places,
vec![
InlineMathPlace {
display_off: 0,
len: 2,
source: 0..3
},
InlineMathPlace {
display_off: 7,
len: 4,
source: 8..11
},
]
);
assert_eq!(nm.len(), nd.len() + 1);
assert_eq!(runs_len(&nr), nd.len());
assert_eq!(*nm.last().unwrap(), display.len()); }
#[test]
fn splice_no_formulas_is_identity() {
let display = "plain text";
let map: Vec<usize> = (0..=display.len()).collect();
let (nd, nr, nm, places) =
splice_inline_math(display, &[run(display.len())], &map, &[], &run(0));
assert_eq!(nd, display);
assert_eq!(nm, map);
assert!(places.is_empty());
assert_eq!(runs_len(&nr), nd.len());
}
#[test]
fn inline_math_multibyte_safe() {
let line = "café $x$ — π";
assert_eq!(spans(line), vec!["$x$"]);
for r in inline_math_spans(line) {
assert!(line.is_char_boundary(r.start) && line.is_char_boundary(r.end));
}
}
#[test]
fn one_line_math_regions() {
let r = math_regions("before\n$$x^2$$\nafter");
assert_eq!(r.len(), 1);
assert_eq!(r[0].range, 1..2);
assert_eq!(r[0].source, "x^2");
let r = math_regions("<!-- math:right -->\n$$x$$");
assert_eq!(r[0].marker_line, Some(0));
assert!(math_regions("$$$$").is_empty());
assert!(math_regions("$$ $$").is_empty());
}
#[test]
fn forgiving_math_forms() {
assert!(math_regions("words $$E=mc^2$$ more").is_empty());
assert!(math_regions("fees are $$5 and $$10 total").is_empty());
assert!(math_regions("wer $$\nu(m)=v(n,x)\n$$ wer").is_empty());
assert!(math_regions("```sh\necho $$\n$$\n```").is_empty());
}
#[test]
fn inline_display_math_double_dollar() {
assert_eq!(spans("$$E=mc^2$$"), vec!["$$E=mc^2$$"]);
assert_eq!(spans("a $$ x + y $$ b"), vec!["$$ x + y $$"]);
assert_eq!(inline_math_latex("$$E=mc^2$$", &(0..10)), "E=mc^2");
assert_eq!(inline_math_latex("$x$", &(0..3)), "x");
assert_eq!(spans("$$"), Vec::<&str>::new());
assert_eq!(spans("$$$$"), Vec::<&str>::new());
assert_eq!(spans("$$x"), Vec::<&str>::new());
assert_eq!(spans("$$a$$ then $b$"), vec!["$$a$$", "$b$"]);
}
#[test]
fn detects_a_gfm_table() {
let md = "intro\n\n| A | B |\n| --- | :-: |\n| 1 | 2 |\n| 3 | 4 |\n\nafter";
let regions = table_regions(md);
assert_eq!(regions.len(), 1);
assert_eq!(regions[0].lines, 2..6); assert_eq!(regions[0].aligns, vec![Align::Left, Align::Center]);
}
#[test]
fn mermaid_block_extraction() {
let md = "intro\n\n```mermaid\ngraph TD\nA --> B\n```\n\nafter";
let blocks = mermaid_blocks(md);
assert_eq!(blocks.len(), 1);
assert_eq!(blocks[0].0, 2..6); assert_eq!(blocks[0].1, "graph TD\nA --> B");
assert!(mermaid_blocks("```rust\nfn x() {}\n```").is_empty());
assert_eq!(mermaid_blocks("```mermaid \npie\n```").len(), 1);
}
#[test]
fn cells_split_and_trim() {
assert_eq!(table_cells("| a | b |"), vec!["a", "b"]);
assert_eq!(table_cells("| | |"), vec!["", ""]);
}
#[test]
fn escaped_pipes_are_cell_content() {
assert_eq!(
table_cells(r"| left \| right | ok |"),
vec![r"left \| right", "ok"]
);
assert_eq!(table_cells(r"| a \\| b |"), vec![r"a \\", "b"]);
let line = r"| left \| right | ok |";
let ranges = table_cell_ranges(line);
assert_eq!(ranges.len(), 2);
assert_eq!(&line[ranges[0].clone()], r"left \| right");
assert_eq!(&line[ranges[1].clone()], "ok");
}
#[test]
fn separator_required_and_alignment() {
assert!(table_regions("a | b\nc | d").is_empty());
assert!(table_regions("| not a table\njust text").is_empty());
assert_eq!(
table_regions("| h |\n| ---: |\n| x |")[0].aligns,
vec![Align::Right]
);
}
#[test]
fn table_style_marker_and_region() {
assert_eq!(
table_style_marker("<!-- table:striped -->"),
Some(TableStyle::Striped)
);
assert_eq!(
table_style_marker("<!--table:minimal-->"),
Some(TableStyle::Minimal)
);
assert_eq!(table_style_marker("<!-- table:bogus -->"), None);
assert_eq!(table_style_marker("<!-- a comment -->"), None);
assert_eq!(table_style_marker("| a | b |"), None);
let r = table_regions("<!-- table:header -->\n| h |\n| --- |\n| x |");
assert_eq!(r.len(), 1);
assert_eq!(r[0].style, TableStyle::Header);
assert_eq!(r[0].marker_line, Some(0));
assert_eq!(r[0].lines, 1..4);
let g = table_regions("| h |\n| --- |\n| x |");
assert_eq!(g[0].style, TableStyle::Grid);
assert_eq!(g[0].marker_line, None);
}
#[test]
fn adjacent_tables_split_into_two_regions() {
let r = table_regions(
"| a | b |\n| --- | --- |\n| 1 | 2 |\n| c | d |\n| --- | --- |\n| 3 | 4 |",
);
assert_eq!(r.len(), 2);
assert_eq!(r[0].lines, 0..3);
assert_eq!(r[1].lines, 3..6);
}
#[test]
fn heading_and_image_line() {
assert_eq!(heading_level("## Hi"), Some(2));
assert_eq!(heading_level("#notaheading"), None);
assert_eq!(image_line(""), Some(("b.png", None)));
assert_eq!(
image_line("{width=320}"),
Some(("b.png", Some(320.0)))
);
assert_eq!(image_line("text "), None);
assert_eq!(
image_row("- {width=452}"),
Some(("b.png", Some(452.0), 2))
);
assert_eq!(image_row("1. "), Some(("b.png", None, 3)));
assert_eq!(image_row(""), Some(("b.png", None, 0)));
assert_eq!(image_row("- text "), None);
}
#[test]
fn link_at_hits_every_linkable_construct() {
use LinkHit::*;
let line = "see [[Ops Net|the net]] and #scada plus [docs](https://x.io/d) `#not` [[]]";
assert_eq!(link_at(line, 4), Some(Page("Ops Net".into())));
assert_eq!(link_at(line, 15), Some(Page("Ops Net".into())));
assert_eq!(link_at(line, 28), Some(Page("scada".into())));
assert_eq!(link_at(line, 33), Some(Page("scada".into())));
assert_eq!(link_at(line, 41), Some(Url("https://x.io/d".into())));
assert_eq!(link_at(line, 55), Some(Url("https://x.io/d".into())));
assert_eq!(link_at(line, 0), None);
assert_eq!(link_at(line, 66), None); assert_eq!(link_at(line, 72), None); assert_eq!(link_at("", 10), None);
assert_eq!(link_at("x [^1] y", 3), None);
}
#[test]
fn heading_nested_in_list_item() {
assert_eq!(line_scale("### Hi"), 1.3);
assert_eq!(line_scale("- ### Hi"), 1.3);
assert_eq!(line_scale(" - ## Hi"), 1.5);
assert_eq!(line_scale("1. # Hi"), 1.8);
assert_eq!(line_scale("- not a heading"), 1.0);
let font = gpui::font("Helvetica");
let c = hsla(0., 0., 0., 1.);
let st = test_style();
let (disp, ..) = hidden_runs("- ### Notes", &font, c, &[], None, 0, 0, false, &st);
assert_eq!(disp, "Notes");
}
fn test_style() -> SyntaxStyle {
let c = hsla(0., 0., 0.5, 1.);
SyntaxStyle {
block_label: None,
block_label_gen: 0,
block_ref_count: None,
marker: c,
code: c,
code_bg: c,
link: c,
tag: c,
quote: c,
alert_note: c,
alert_tip: c,
alert_important: c,
alert_warning: c,
alert_caution: c,
alert_icons: None,
rule: c,
mark_bg: c,
popover_bg: c,
popover_border: c,
popover_fg: c,
popover_hover: c,
popover_divider: c,
popover_danger: c,
mono: gpui::font("monospace"),
property_icon: None,
}
}
#[test]
fn thematic_break_detection() {
for s in ["---", "***", "___", "- - -", " --- ", "----"] {
assert!(thematic_break(s), "{s:?} should be a rule");
}
for s in ["--", "**", "", "text", "---x", "- -- text", "===", "> ---"] {
assert!(!thematic_break(s), "{s:?} should not be a rule");
}
}
#[test]
fn footnote_def_and_html_block() {
assert_eq!(footnote_def("[^1]: a note"), Some(5)); assert_eq!(footnote_def("[^note]:x"), Some(8)); assert_eq!(footnote_def("[^1] not a def"), None);
assert_eq!(footnote_def("[^]: empty label"), None);
assert_eq!(footnote_def("plain"), None);
assert!(html_block("<div>"));
assert!(html_block("</section>"));
assert!(html_block("<!-- comment -->"));
assert!(html_block(" <span>indented"));
assert!(!html_block("< 5 items"));
assert!(!html_block("text <b>inline</b>"));
}
#[test]
fn hidden_runs_removes_markers_and_maps_back() {
let font = gpui::font("Helvetica");
let c = hsla(0., 0., 0., 1.);
let st = test_style();
let (disp, _, map) = hidden_runs("**bold**", &font, c, &[], None, 0, 0, false, &st);
assert_eq!(disp, "bold");
assert_eq!(map.len(), disp.len() + 1);
assert_eq!(map[0], 2);
assert_eq!(map[4], 8);
let (disp, _, map) = hidden_runs("## Hi", &font, c, &[], None, 0, 0, false, &st);
assert_eq!(disp, "Hi");
assert_eq!(map[0], 3);
assert_eq!(map[2], 5);
let (disp, _, map) = hidden_runs("plain text", &font, c, &[], None, 0, 0, false, &st);
assert_eq!(disp, "plain text");
assert_eq!(map, (0..=10).collect::<Vec<_>>());
}
#[test]
fn hidden_runs_image_link_hides_the_bang_too() {
let font = gpui::font("Helvetica");
let c = hsla(0., 0., 0., 1.);
let st = test_style();
let (disp, ..) = hidden_runs("", &font, c, &[], None, 0, 0, false, &st);
assert_eq!(disp, "");
let (disp, ..) = hidden_runs(
"",
&font,
c,
&[],
None,
0,
0,
false,
&st,
);
assert_eq!(disp, "alt");
}
#[test]
fn hidden_runs_squiggle_diagnostics() {
let font = gpui::font("Helvetica");
let c = hsla(0., 0., 0., 1.);
let st = test_style();
let (disp, runs, _) = hidden_runs(
"**bold**",
&font,
c,
&[Diagnostic { range: 2..6 }],
None,
0,
0,
false,
&st,
);
assert_eq!(disp, "bold");
assert_eq!(runs.iter().map(|r| r.len).sum::<usize>(), disp.len());
assert!(runs.iter().all(|r| r.underline.is_some()));
let (disp, runs, _) = hidden_runs(
"plain text",
&font,
c,
&[Diagnostic { range: 6..10 }],
None,
0,
0,
false,
&st,
);
assert_eq!(disp, "plain text");
let underlined: usize = runs
.iter()
.filter(|r| r.underline.is_some())
.map(|r| r.len)
.sum();
assert_eq!(underlined, 4); }
#[test]
fn hidden_runs_reveals_only_caret_construct() {
let font = gpui::font("Helvetica");
let c = hsla(0., 0., 0., 1.);
let st = test_style();
let line = "**bold** *it*";
let (disp, _, _) = hidden_runs(line, &font, c, &[], Some(4), 0, 0, false, &st);
assert_eq!(disp, "bold it");
let (disp, _, _) = hidden_runs("a **b**", &font, c, &[], Some(0), 0, 0, false, &st);
assert_eq!(disp, "a b");
let (disp, _, _) = hidden_runs(line, &font, c, &[], None, 0, 0, false, &st);
assert_eq!(disp, "bold it");
let link = "a [t](u) b";
let (disp, _, _) = hidden_runs(link, &font, c, &[], Some(3), 0, 0, false, &st);
assert_eq!(disp, "a [t](u) b");
let (disp, _, _) = hidden_runs(link, &font, c, &[], Some(0), 0, 0, false, &st);
assert_eq!(disp, "a t b");
}
#[test]
fn alert_kinds_detected() {
assert_eq!(alert_kind("[!NOTE]"), Some(AlertKind::Note));
assert_eq!(alert_kind(" [!CAUTION] "), Some(AlertKind::Caution));
assert_eq!(alert_kind("[!NOTE] text after"), Some(AlertKind::Note));
for s in ["[!note]", "[!NOTEXT]", "note", ""] {
assert_eq!(alert_kind(s), None, "{s:?}");
}
}
#[test]
fn blockquote_prefix_and_hidden() {
assert_eq!(blockquote_prefix("> quote"), Some(2));
assert_eq!(blockquote_prefix(">no space"), Some(1));
assert_eq!(blockquote_prefix(">> nested"), Some(3));
assert_eq!(blockquote_prefix("not a quote"), None);
let font = gpui::font("Helvetica");
let c = hsla(0., 0., 0., 1.);
let st = test_style();
let (disp, _, map) = hidden_runs("> a **b**", &font, c, &[], None, 0, 0, false, &st);
assert_eq!(disp, "a b"); assert_eq!(map[0], 2);
let (disp, _, _) = hidden_runs("> a **b**", &font, c, &[], Some(3), 2, 0, false, &st);
assert_eq!(disp, "> a b");
}
#[test]
fn backtick_runs_match_commonmark() {
let st = test_style();
let line = "``a`b`` x";
let mut out = Vec::new();
scan_line(line, 0, line.len(), &st, &mut out);
assert!(
out.iter()
.any(|s| s.range == (2..5) && s.style.bg.is_some()),
"body a`b styled as code"
);
let mut out = Vec::new();
scan_line("``a` x", 0, 6, &st, &mut out);
assert!(out.iter().all(|s| s.style.bg.is_none()));
}
#[test]
fn backslash_escapes_stay_literal() {
let st = test_style();
for line in [
r"\*text\*",
r"a \`x\` b",
r"\~~s\~~",
r"\[[Page]]",
r"\[t](u)",
] {
let mut out = Vec::new();
scan_line(line, 0, line.len(), &st, &mut out);
assert!(
out.iter().all(|s| !s.style.bold
&& !s.style.italic
&& !s.style.strike
&& s.style.bg.is_none()
&& (s.style.hide || s.style.color.is_none())),
"{line:?} styled: {:?}",
out.iter().map(|s| s.range.clone()).collect::<Vec<_>>()
);
}
let mut out = Vec::new();
let line = r"**a \** x";
scan_line(line, 0, line.len(), &st, &mut out);
assert!(out.iter().all(|s| !s.style.bold), "escaped closer ignored");
}
#[test]
fn escape_backslash_hides_like_the_reader() {
let font = gpui::font("Helvetica");
let c = hsla(0., 0., 0., 1.);
let st = test_style();
let (disp, _, _) = hidden_runs(r"\*text\*", &font, c, &[], None, 0, 0, false, &st);
assert_eq!(disp, "*text*");
let (disp, _, _) = hidden_runs(r"a \\ b", &font, c, &[], None, 0, 0, false, &st);
assert_eq!(disp, r"a \ b");
let (disp, _, _) = hidden_runs(r"\**bold**", &font, c, &[], None, 0, 0, false, &st);
assert_eq!(disp, "*bold*");
}
#[test]
fn nested_inline_styles_layer() {
let st = test_style();
let line = "**bold *it* bold**";
let mut out = Vec::new();
scan_line(line, 0, line.len(), &st, &mut out);
let styled = |s: usize, e: usize| out.iter().find(|sp| sp.range == (s..e)).unwrap();
let inner = styled(8, 10); assert!(inner.style.bold && inner.style.italic);
let gap = styled(2, 7); assert!(gap.style.bold && !gap.style.italic);
let line = "**b `c` d**";
let mut out = Vec::new();
scan_line(line, 0, line.len(), &st, &mut out);
let code = out
.iter()
.find(|sp| sp.range == (5..6))
.expect("code body span");
assert!(code.style.bold && code.style.bg.is_some());
let font = gpui::font("Helvetica");
let c = hsla(0., 0., 0., 1.);
let (disp, _, _) = hidden_runs("**bold *it* bold**", &font, c, &[], None, 0, 0, false, &st);
assert_eq!(disp, "bold it bold");
let pairs = fmt_marker_pairs("**bold *it* bold**", &st);
assert_eq!(pairs, vec![(0..2, 16..18), (7..8, 10..11)]);
}
#[test]
fn fmt_marker_pairs_stay_per_construct() {
let st = test_style();
assert_eq!(
fmt_marker_pairs("**a**~~b~~", &st),
vec![(0..2, 3..5), (5..7, 8..10)]
);
assert_eq!(fmt_marker_pairs("****", &st), vec![(0..2, 2..4)]);
assert_eq!(fmt_marker_pairs("[t](u)", &st), vec![]);
assert_eq!(fmt_marker_pairs("<u>x</u>", &st), vec![(0..3, 4..8)]);
}
#[test]
fn formatting_markers_never_reveal() {
let font = gpui::font("Helvetica");
let c = hsla(0., 0., 0., 1.);
let st = test_style();
for (line, disp) in [
("**bold** x", "bold x"),
("*it* x", "it x"),
("~~s~~ x", "s x"),
("`c` x", "c x"),
("<u>u</u> x", "u x"),
("<mark>m</mark> x", "m x"),
] {
let caret_inside = Some(3.min(line.len() - 1));
let (d, _, _) = hidden_runs(line, &font, c, &[], caret_inside, 0, 0, false, &st);
assert_eq!(d, disp, "{line:?}");
}
let (d, _, _) = hidden_runs("[[Page]] x", &font, c, &[], Some(4), 0, 0, false, &st);
assert_eq!(d, "[[Page]] x");
}
#[test]
fn ordered_numbers_word_style() {
assert_eq!(
ordered_numbers(&["1. a", "2. b", " 3. c", " 9. d", "6. e"]),
vec![(1, 0), (2, 0), (1, 1), (2, 1), (3, 0)]
);
assert_eq!(ordered_numbers(&["5. a", "9. b"]), vec![(1, 0), (2, 0)]);
assert_eq!(
ordered_numbers(&["1. a", "- b", "7. c"]),
vec![(1, 0), (0, 0), (1, 0)]
);
assert_eq!(
ordered_numbers(&["1. a", "", "2. b"]),
vec![(1, 0), (0, 0), (1, 0)]
);
assert_eq!(
ordered_numbers(&["1. a", "prose", "7. b"]),
vec![(1, 0), (0, 0), (1, 0)]
);
assert_eq!(
ordered_numbers(&["1. a", " wrapped", "7. b"]),
vec![(1, 0), (0, 0), (2, 0)]
);
}
#[test]
fn list_prefix_detection() {
assert_eq!(list_prefix("- item"), Some((2, 0, false, 0)));
assert_eq!(list_prefix("* item"), Some((2, 0, false, 0)));
assert_eq!(list_prefix("+ item"), Some((2, 0, false, 0)));
assert_eq!(list_prefix(" - nested"), Some((4, 2, false, 0)));
assert_eq!(list_prefix("3. third"), Some((3, 0, true, 3)));
assert_eq!(list_prefix(" 10) tenth"), Some((6, 2, true, 10)));
assert_eq!(list_prefix("*italic*"), None);
assert_eq!(list_prefix("-no space"), None);
assert_eq!(list_prefix(" just indented"), None);
let font = gpui::font("Helvetica");
let c = hsla(0., 0., 0., 1.);
let st = test_style();
let (disp, _, map) = hidden_runs(" - hi", &font, c, &[], None, 0, 0, false, &st);
assert_eq!(disp, "hi");
assert_eq!(map[0], 4); }
#[test]
fn task_prefix_detection() {
assert_eq!(task_prefix("- [ ] todo"), Some((6, 0, false)));
assert_eq!(task_prefix("- [x] done"), Some((6, 0, true)));
assert_eq!(task_prefix("- [X] done"), Some((6, 0, true)));
assert_eq!(task_prefix(" - [ ] nested"), Some((8, 2, false)));
assert_eq!(task_prefix("- item"), None);
let font = gpui::font("Helvetica");
let c = hsla(0., 0., 0., 1.);
let st = test_style();
let (disp, _, map) = hidden_runs("- [x] go", &font, c, &[], None, 0, 0, false, &st);
assert_eq!(disp, "go");
assert_eq!(map[0], 6); }
#[test]
fn toggle_task_checkbox_flips() {
assert_eq!(
toggle_task_checkbox("- [ ] todo").as_deref(),
Some("- [x] todo")
);
assert_eq!(
toggle_task_checkbox("- [x] done").as_deref(),
Some("- [ ] done")
);
assert_eq!(
toggle_task_checkbox("- [X] done").as_deref(),
Some("- [ ] done")
);
assert_eq!(
toggle_task_checkbox(" - [ ] nested").as_deref(),
Some(" - [x] nested")
);
assert_eq!(toggle_task_checkbox("- plain"), None);
}
#[test]
fn alert_fold_regions_span_the_quote_block() {
let src = "> [!NOTE]- hidden\n> body\n> more\nprose\n> [!TIP]+ open\n> b\n> [!NOTE] plain";
let r = alert_fold_regions(src);
assert_eq!(r, vec![(0..3, true), (4..6, false)]);
}
#[test]
fn heading_fold_regions_span_their_section() {
let src = "# Top\nbody\n## Sub\nsub body\n```\n# not a heading\n```\n## Next\nx";
let folded: std::collections::HashSet<String> = ["## Sub".to_string()].into();
assert_eq!(heading_fold_regions(src, &folded), vec![2..7]);
let folded: std::collections::HashSet<String> = ["# Top".to_string()].into();
assert_eq!(heading_fold_regions(src, &folded), vec![0..9]);
assert!(heading_fold_regions(src, &Default::default()).is_empty());
let src = "* ### Notes: ^abc\n - one\n\n - two\n* sibling\n# After";
let folded: std::collections::HashSet<String> = ["* ### Notes: ^abc".to_string()].into();
assert_eq!(heading_fold_regions(src, &folded), vec![0..4]);
}
#[test]
fn property_regions_group_and_skip_code() {
let r = property_regions("attendees:: Bob\ntime:: 3pm\n\nprose\nowner:: Sue");
assert_eq!(r, vec![0..2, 4..5]);
let r2 = property_regions("```rust\nFoo::bar()\n```\nkey:: v");
assert_eq!(r2, vec![3..4]);
let r3 = property_regions("- status:: open\n time:: 3pm\n- plain bullet");
assert_eq!(r3, vec![0..2]);
}
}