use std::ops::Range;
use twig::{FlatNode, Kind};
use crate::style::{Baseline, Role, Style};
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct StyledRun {
pub span: Range<usize>,
pub style: Style,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct SourceMap {
runs: Vec<StyledRun>,
}
impl SourceMap {
pub fn runs(&self) -> &[StyledRun] {
&self.runs
}
pub fn is_empty(&self) -> bool {
self.runs.is_empty()
}
pub fn style_at(&self, offset: usize) -> Style {
match self.runs.binary_search_by(|r| {
if r.span.end <= offset {
std::cmp::Ordering::Less
} else if offset < r.span.start {
std::cmp::Ordering::Greater
} else {
std::cmp::Ordering::Equal
}
}) {
Ok(i) => self.runs[i].style,
Err(_) => Style::default(),
}
}
pub fn edges_in(&self, range: Range<usize>, out: &mut Vec<usize>) {
let from = self.runs.partition_point(|r| r.span.end <= range.start);
let mut last = None;
for run in &self.runs[from..] {
if run.span.start >= range.end {
break;
}
for edge in [run.span.start, run.span.end] {
if edge > range.start && edge < range.end && last != Some(edge) {
out.push(edge);
last = Some(edge);
}
}
}
}
}
pub fn build(nodes: &[FlatNode], source: &str) -> SourceMap {
let Some(root) = nodes.iter().position(|n| n.kind == Kind::Doc) else {
return SourceMap::default();
};
let len = source.len();
if len == 0 {
return SourceMap::default();
}
let mut paint = vec![Style::default(); len];
let mut stack = vec![(root, Style::default())];
while let Some((id, base)) = stack.pop() {
let node = &nodes[id];
let style = style_of(node, base);
if style.role == Role::Delimiter {
fill_markup(&mut paint, source, &node.span, style);
} else {
fill(&mut paint, &node.span, style);
}
if let Some(content) = &node.content_span {
let delim = style.role(Role::Delimiter);
fill_markup(&mut paint, source, &(node.span.start..content.start), delim);
fill_markup(&mut paint, source, &(content.end..node.span.end), delim);
}
let mut child = node.first_child;
while let Some(cid) = child {
let i = cid.0 as usize;
let Some(n) = nodes.get(i) else { break };
stack.push((i, style));
child = n.next_sibling;
}
}
SourceMap {
runs: to_runs(paint),
}
}
fn fill(paint: &mut [Style], span: &Range<usize>, style: Style) {
let start = span.start.min(paint.len());
let end = span.end.min(paint.len());
if start < end {
paint[start..end].fill(style);
}
}
fn fill_markup(paint: &mut [Style], source: &str, span: &Range<usize>, style: Style) {
let blank = source
.get(span.start.min(source.len())..span.end.min(source.len()))
.is_none_or(|s| s.trim().is_empty());
if !blank {
fill(paint, span, style);
}
}
fn to_runs(paint: Vec<Style>) -> Vec<StyledRun> {
let mut runs: Vec<StyledRun> = Vec::new();
let mut start = 0usize;
for i in 1..=paint.len() {
if i < paint.len() && paint[i] == paint[start] {
continue;
}
if paint[start] != Style::default() {
runs.push(StyledRun {
span: start..i,
style: paint[start],
});
}
start = i;
}
runs
}
fn style_of(node: &FlatNode, base: Style) -> Style {
match node.kind {
Kind::Heading => base.role(Role::Heading(node.level.unwrap_or(1).clamp(1, 255) as u8)),
Kind::Emph => base.italic(),
Kind::Strong => base.bold(),
Kind::Mark => base.role(Role::Mark),
Kind::Insert => base.underline(),
Kind::Delete => base.strikethrough(),
Kind::Superscript => base.baseline(Baseline::Super),
Kind::Subscript => base.baseline(Baseline::Sub),
Kind::CodeBlock
| Kind::Verbatim
| Kind::InlineMath
| Kind::DisplayMath
| Kind::RawBlock
| Kind::RawInline => base.role(Role::Code),
Kind::Link
| Kind::Url
| Kind::Email
| Kind::Reference
| Kind::Citation
| Kind::FootnoteReference
| Kind::CitationReference
| Kind::SubstitutionReference => base.role(Role::Link),
Kind::ThematicBreak => base.role(Role::Rule),
Kind::Comment | Kind::Doctype | Kind::ProcessingInstruction | Kind::Cdata => {
base.role(Role::Delimiter)
}
Kind::SoftBreak => base.role(Role::Delimiter),
_ => base,
}
}
#[cfg(test)]
mod tests {
use super::*;
use twig::{Editor, Format};
fn map(src: &str, format: Format) -> SourceMap {
let mut ed = Editor::new_str(src, format).unwrap();
let nodes = ed.nodes().unwrap();
build(&nodes, src)
}
fn md(src: &str) -> SourceMap {
map(src, Format::Markdown)
}
fn where_style(m: &SourceMap, src: &str, pred: impl Fn(Style) -> bool) -> String {
(0..src.len())
.filter(|&i| src.is_char_boundary(i) && pred(m.style_at(i)))
.filter_map(|i| src[i..].chars().next())
.collect()
}
#[test]
fn a_headings_hash_is_markup_and_its_text_is_a_heading() {
let src = "# Title\n";
let m = md(src);
assert_eq!(
where_style(&m, src, |s| s.role == Role::Delimiter),
"# ",
"the `# ` opens the heading and is not part of it"
);
assert_eq!(
where_style(&m, src, |s| s.role == Role::Heading(1)),
"Title",
"the text is the heading"
);
}
#[test]
fn a_links_destination_is_markup_and_its_label_is_a_link() {
let src = "see [here](https://example.dev) now\n";
let m = md(src);
assert_eq!(where_style(&m, src, |s| s.role == Role::Link), "here");
assert_eq!(
where_style(&m, src, |s| s.role == Role::Delimiter),
"[](https://example.dev)",
"the brackets and the destination are the link's markup"
);
}
#[test]
fn emphasis_inside_a_heading_is_both() {
let src = "## a *b* c\n";
let m = md(src);
let b = src.find('b').unwrap();
let style = m.style_at(b);
assert_eq!(style.role, Role::Heading(2), "still heading text");
assert!(style.italic, "and italic");
}
#[test]
fn a_marks_delimiters_keep_the_emphasis_they_delimit() {
let src = "a **b** c\n";
let m = md(src);
let star = src.find('*').unwrap();
assert_eq!(m.style_at(star).role, Role::Delimiter);
assert!(m.style_at(star).bold, "the `**` belongs to the bold run");
assert!(m.style_at(src.find('b').unwrap()).bold);
assert_eq!(m.style_at(src.find('b').unwrap()).role, Role::Body);
}
#[test]
fn a_fence_is_markup_and_the_body_is_code() {
let src = "```rust\nfn main() {}\n```\n";
let m = md(src);
assert_eq!(
m.style_at(src.find("fn").unwrap()).role,
Role::Code,
"the body of the block is code"
);
assert_eq!(
m.style_at(0).role,
Role::Delimiter,
"the opening fence is markup"
);
assert_eq!(
m.style_at(src.rfind("```").unwrap()).role,
Role::Delimiter,
"and so is the closing one"
);
}
#[test]
fn frontmatter_fences_are_markup() {
let src = "---\ntitle: x\n---\n\ntext\n";
let m = md(src);
assert_eq!(m.style_at(0).role, Role::Delimiter, "the opening `---`");
assert_eq!(
m.style_at(src.find("title").unwrap()).role,
Role::Body,
"the metadata itself is text"
);
}
#[test]
fn list_markers_and_quote_gutters_are_markup() {
let src = "- one\n- [ ] two\n";
let m = md(src);
assert_eq!(m.style_at(0).role, Role::Delimiter, "the `- `");
assert_eq!(m.style_at(src.find("one").unwrap()).role, Role::Body);
let box_at = src.find("[ ]").unwrap();
assert_eq!(m.style_at(box_at).role, Role::Delimiter, "the task box");
}
#[test]
fn a_quotes_continuation_marker_is_markup_too() {
let src = "> one\n> two\n";
let m = md(src);
assert_eq!(m.style_at(0).role, Role::Delimiter, "the opening `> `");
let second = src.rfind('>').unwrap();
assert_eq!(
m.style_at(second).role,
Role::Delimiter,
"and the one on the second line"
);
assert_eq!(m.style_at(src.find("two").unwrap()).role, Role::Body);
}
#[test]
fn every_format_styles_bold_the_same_way() {
for (format, src, word) in [
(Format::Markdown, "a **b** c\n", "b"),
(Format::Djot, "a *b* c\n", "b"),
(Format::Html, "<p>a <b>bee</b> c</p>\n", "bee"),
] {
let m = map(src, format);
let at = src.find(word).unwrap();
assert!(
m.style_at(at).bold,
"{format:?} should style {word:?} bold in {src:?}"
);
assert_eq!(
m.style_at(at).role,
Role::Body,
"{format:?}: the bold text is prose, not markup"
);
}
}
#[test]
fn html_tags_are_markup_and_a_comment_is_dim_throughout() {
let src = "<h1>Title</h1>\n<!-- note -->\n";
let m = map(src, Format::Html);
assert_eq!(m.style_at(0).role, Role::Delimiter, "the `<h1>` tag");
assert_eq!(
m.style_at(src.find("Title").unwrap()).role,
Role::Heading(1),
"what the tag contains is a heading"
);
assert!(
where_style(&m, src, |s| s.role == Role::Delimiter).contains("note"),
"a comment is machinery all the way through"
);
}
#[test]
fn plain_prose_styles_nothing() {
let m = md("Just a sentence with no markup in it at all.\n");
assert!(m.is_empty(), "no runs, so a painter does no extra work");
}
#[test]
fn an_empty_document_is_an_empty_map() {
assert!(md("").is_empty());
}
#[test]
fn runs_are_ascending_disjoint_and_never_default() {
let src =
"---\na: b\n---\n\n# H *i*\n\n- [ ] t `c`\n\n> q\n> r\n\n```rs\nx\n```\n\n[l](d)\n";
let m = md(src);
assert!(!m.is_empty());
let mut prev = 0;
for run in m.runs() {
assert!(run.span.start < run.span.end, "no empty runs: {run:?}");
assert!(run.span.start >= prev, "ascending and disjoint: {run:?}");
assert_ne!(run.style, Style::default(), "no default runs: {run:?}");
assert!(run.span.end <= src.len(), "inside the source: {run:?}");
prev = run.span.end;
}
}
#[test]
fn style_at_agrees_with_the_runs_it_reads() {
let src = "# H\n\ntext **b** and `c` and [l](d)\n";
let m = md(src);
for run in m.runs() {
for i in run.span.clone() {
assert_eq!(m.style_at(i), run.style, "byte {i}");
}
}
let gap = src.find("text").unwrap();
assert_eq!(m.style_at(gap), Style::default());
}
#[test]
fn edges_in_reports_every_boundary_inside_the_line_and_none_outside() {
let src = "a **b** c\n";
let m = md(src);
let mut cuts = Vec::new();
m.edges_in(0..src.len(), &mut cuts);
assert_eq!(cuts, vec![2, 4, 5, 7]);
let mut cuts = Vec::new();
m.edges_in(0..5, &mut cuts);
assert_eq!(cuts, vec![2, 4]);
}
#[test]
fn edges_in_answers_for_a_line_in_the_middle_of_a_document() {
let src = "# One\n\ntwo **three** four\n\n# Five\n";
let m = md(src);
let line_start = src.find("two").unwrap();
let line_end = src[line_start..].find('\n').unwrap() + line_start;
let mut cuts = Vec::new();
m.edges_in(line_start..line_end, &mut cuts);
assert!(
cuts.iter().all(|&c| c > line_start && c < line_end),
"every cut lands inside the line: {cuts:?}"
);
assert_eq!(cuts.len(), 4, "the two `**` pairs and the word between");
}
#[test]
fn a_span_past_the_end_of_the_source_is_clipped_not_panicked() {
let mut ed = Editor::new_str("# H\n", Format::Markdown).unwrap();
let nodes = ed.nodes().unwrap();
let m = build(&nodes, "# ");
for run in m.runs() {
assert!(run.span.end <= 2, "clipped to the length given: {run:?}");
}
}
}