use std::collections::{BTreeSet, HashMap, HashSet, VecDeque};
const TAB: usize = 4;
const MAX_DEPTH: usize = 160;
const MAX_INDENT_WORK: usize = 100_000_000;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum Leaf {
Marker,
Para(usize),
Code(usize),
Heading(usize),
}
#[derive(Clone)]
struct Ln {
idx: usize,
text: String,
}
type Block = Vec<Ln>;
#[derive(Clone, Copy, PartialEq, Eq)]
enum Tag {
Root,
List,
Li,
P,
Pre,
}
struct Node {
tag: Tag,
children: Vec<usize>,
text: Option<usize>,
tail: Option<usize>,
ordered: bool,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum State {
List,
LooseList,
Detabbed,
}
struct Parser {
nodes: Vec<Node>,
state: Vec<State>,
kinds: Vec<Vec<Leaf>>,
code_text: Vec<Option<String>>,
line_list: Vec<Option<(usize, bool)>>,
next_id: usize,
budget: usize,
depth: usize,
overrun: bool,
}
fn leading_spaces(text: &str) -> usize {
text.bytes().take_while(|&b| b == b' ').count()
}
fn is_blank(text: &str) -> bool {
text.trim().is_empty()
}
fn marker_len(rest: &str, ordered_ok: bool, bullet_ok: bool) -> Option<usize> {
let bytes = rest.as_bytes();
match bytes.first()? {
b'*' | b'+' | b'-' if bullet_ok => Some(1),
b'0'..=b'9' if ordered_ok => {
let digits = bytes.iter().take_while(|b| b.is_ascii_digit()).count();
(bytes.get(digits) == Some(&b'.')).then_some(digits + 1)
}
_ => None,
}
}
fn item_match(text: &str, min: usize, max: usize, ordered_ok: bool, bullet_ok: bool) -> Option<&str> {
let indent = leading_spaces(text);
if indent < min || indent > max {
return None;
}
let rest = &text[indent..];
let len = marker_len(rest, ordered_ok, bullet_ok)?;
let after = &rest[len..];
let spaces = leading_spaces(after);
(spaces > 0).then(|| &after[spaces..])
}
fn child_match(text: &str) -> Option<&str> {
item_match(text, 0, TAB - 1, true, true)
}
fn indent_item_match(text: &str) -> bool {
item_match(text, TAB, TAB * 2 - 1, true, true).is_some()
}
fn olist_test(block: &Block) -> bool {
block
.first()
.is_some_and(|l| item_match(&l.text, 0, TAB - 1, true, false).is_some())
}
fn ulist_test(block: &Block) -> bool {
block
.first()
.is_some_and(|l| item_match(&l.text, 0, TAB - 1, false, true).is_some())
}
fn starts_with_tab(block: &Block) -> bool {
block.first().is_some_and(|l| l.text.starts_with(" "))
}
fn joined_nonempty(block: &Block) -> bool {
block.len() > 1 || block.first().is_some_and(|l| !l.text.is_empty())
}
fn split_blocks(lines: &[Ln]) -> VecDeque<Block> {
let mut joined = String::new();
let mut offsets = Vec::with_capacity(lines.len());
for (i, line) in lines.iter().enumerate() {
if i > 0 {
joined.push('\n');
}
offsets.push(joined.len());
joined.push_str(&line.text);
}
let mut out = VecDeque::new();
let mut start = 0;
let mut next_line = 0;
loop {
let (end, next) = match joined[start..].find("\n\n") {
Some(p) => (start + p, Some(start + p + 2)),
None => (joined.len(), None),
};
let mut block = Vec::new();
while next_line < lines.len() && offsets[next_line] <= end {
if offsets[next_line] >= start {
block.push(lines[next_line].clone());
}
next_line += 1;
}
out.push_back(block);
match next {
Some(n) => start = n,
None => break,
}
}
out
}
fn detab(block: Block) -> (Block, Block) {
let mut taken = Vec::new();
let mut iter = block.into_iter();
let mut rest = Vec::new();
for line in iter.by_ref() {
if line.text.starts_with(" ") {
taken.push(Ln {
idx: line.idx,
text: line.text[TAB..].to_string(),
});
} else if is_blank(&line.text) {
taken.push(Ln {
idx: line.idx,
text: String::new(),
});
} else {
rest.push(line);
break;
}
}
rest.extend(iter);
(taken, rest)
}
fn loose_detab(block: Block, level: usize) -> Block {
let pad = " ".repeat(TAB * level);
block
.into_iter()
.map(|l| {
if l.text.starts_with(&pad) {
Ln {
idx: l.idx,
text: l.text[pad.len()..].to_string(),
}
} else {
l
}
})
.collect()
}
impl Parser {
fn new(total_lines: usize) -> Self {
Parser {
nodes: vec![Node {
tag: Tag::Root,
children: Vec::new(),
text: None,
tail: None,
ordered: false,
}],
state: Vec::new(),
kinds: vec![Vec::new(); total_lines],
code_text: vec![None; total_lines],
line_list: vec![None; total_lines],
next_id: 0,
budget: 64 * total_lines + 4096,
depth: 0,
overrun: false,
}
}
fn is_state(&self, s: State) -> bool {
self.state.last() == Some(&s)
}
fn fresh_id(&mut self) -> usize {
self.next_id += 1;
self.next_id
}
fn sub_element(&mut self, parent: usize, tag: Tag) -> usize {
let id = self.nodes.len();
self.nodes.push(Node {
tag,
children: Vec::new(),
text: None,
tail: None,
ordered: false,
});
self.nodes[parent].children.push(id);
id
}
fn last_child(&self, parent: usize) -> Option<usize> {
self.nodes[parent].children.last().copied()
}
fn tag(&self, node: usize) -> Tag {
self.nodes[node].tag
}
fn record(&mut self, block: &Block, leaf: impl Fn(&Ln) -> Option<Leaf>) {
for line in block {
if let Some(l) = leaf(line)
&& let Some(slot) = self.kinds.get_mut(line.idx)
{
slot.push(l);
}
}
}
fn parse_chunk(&mut self, parent: usize, block: &Block) {
let blocks = split_blocks(block);
self.parse_blocks(parent, blocks);
}
fn parse_blocks(&mut self, parent: usize, mut blocks: VecDeque<Block>) {
if self.depth >= MAX_DEPTH {
self.overrun = true;
return;
}
self.depth += 1;
while let Some(front) = blocks.front() {
if self.overrun || self.budget == 0 {
self.overrun = true;
break;
}
self.budget -= 1;
let blank = !joined_nonempty(front);
let leading_empty = front.len() >= 2 && front[0].text.is_empty();
if blank || leading_empty {
self.run_empty(&mut blocks);
} else if self.indent_test(parent, front) {
self.run_indent(parent, &mut blocks);
} else if starts_with_tab(front) {
self.run_code(parent, &mut blocks);
} else if let Some(at) = front.iter().position(|l| l.text.starts_with('#')) {
self.run_header(parent, &mut blocks, at);
} else if olist_test(front) || ulist_test(front) {
self.run_list(parent, &mut blocks);
} else {
self.run_paragraph(parent, &mut blocks);
}
}
self.depth -= 1;
}
fn run_empty(&mut self, blocks: &mut VecDeque<Block>) {
let mut block = blocks.pop_front().unwrap_or_default();
if joined_nonempty(&block) {
block.remove(0);
if joined_nonempty(&block) {
blocks.push_front(block);
}
}
}
fn indent_test(&self, parent: usize, block: &Block) -> bool {
starts_with_tab(block)
&& !self.is_state(State::Detabbed)
&& (self.tag(parent) == Tag::Li || self.last_child(parent).is_some_and(|c| self.tag(c) == Tag::List))
}
fn get_level(&self, parent: usize, block: &Block) -> (usize, usize) {
let indent_level = block.first().map_or(0, |l| leading_spaces(&l.text) / TAB);
let mut level = usize::from(self.is_state(State::List));
let mut parent = parent;
while indent_level > level {
match self.last_child(parent) {
Some(child) if matches!(self.tag(child), Tag::List | Tag::Li) => {
if self.tag(child) == Tag::List {
level += 1;
}
parent = child;
}
_ => break,
}
}
(level, parent)
}
fn run_indent(&mut self, parent: usize, blocks: &mut VecDeque<Block>) {
let block = blocks.pop_front().unwrap_or_default();
let (level, sibling) = self.get_level(parent, &block);
let block = loose_detab(block, level);
self.state.push(State::Detabbed);
if self.tag(parent) == Tag::Li {
match self.last_child(parent) {
Some(c) if self.tag(c) == Tag::List => self.parse_blocks(c, VecDeque::from([block])),
_ => self.parse_blocks(parent, VecDeque::from([block])),
}
} else if self.tag(sibling) == Tag::Li {
self.parse_blocks(sibling, VecDeque::from([block]));
} else if let Some(li) = self.last_child(sibling).filter(|&c| self.tag(c) == Tag::Li) {
self.wrap_text(li);
self.parse_chunk(li, &block);
} else {
let li = self.sub_element(sibling, Tag::Li);
self.parse_blocks(li, VecDeque::from([block]));
}
self.state.pop();
}
fn wrap_text(&mut self, li: usize) {
if let Some(id) = self.nodes[li].text.take() {
let p = self.nodes.len();
self.nodes.push(Node {
tag: Tag::P,
children: Vec::new(),
text: Some(id),
tail: None,
ordered: false,
});
self.nodes[li].children.insert(0, p);
}
}
fn run_code(&mut self, parent: usize, blocks: &mut VecDeque<Block>) {
let sibling = self.last_child(parent);
let block = blocks.pop_front().unwrap_or_default();
let pre = match sibling {
Some(s) if self.tag(s) == Tag::Pre => s,
_ => self.sub_element(parent, Tag::Pre),
};
let (taken, rest) = detab(block);
self.record(&taken, |l| (!is_blank(&l.text)).then_some(Leaf::Code(pre)));
for line in taken.iter().filter(|l| !is_blank(&l.text)) {
if let Some(slot) = self.code_text.get_mut(line.idx) {
*slot = Some(line.text.clone());
}
}
if joined_nonempty(&rest) {
blocks.push_front(rest);
}
}
fn get_items(&mut self, block: Block) -> Vec<Block> {
let mut items: Vec<Block> = Vec::new();
for line in block {
if let Some(content) = child_match(&line.text) {
let content = content.to_string();
if let Some(slot) = self.kinds.get_mut(line.idx) {
slot.push(Leaf::Marker);
}
items.push(vec![Ln {
idx: line.idx,
text: content,
}]);
} else if indent_item_match(&line.text) {
match items.last_mut() {
Some(last) if last[0].text.starts_with(" ") => last.push(line),
_ => items.push(vec![line]),
}
} else if let Some(last) = items.last_mut() {
last.push(line);
} else {
items.push(vec![line]);
}
}
items
}
fn note_list(&mut self, list: usize, item: &Block) {
if let (Some(first), ordered) = (item.first(), self.nodes[list].ordered)
&& let Some(slot) = self.line_list.get_mut(first.idx)
{
*slot = Some((list, ordered));
}
}
fn run_list(&mut self, parent: usize, blocks: &mut VecDeque<Block>) {
let block = blocks.pop_front().unwrap_or_default();
let ordered = olist_test(&block);
let mut items = self.get_items(block).into_iter();
let sibling = self.last_child(parent);
let lst = match sibling {
Some(s) if self.tag(s) == Tag::List => {
if let Some(&last_li) = self.nodes[s].children.last() {
self.wrap_text(last_li);
if let Some(lch) = self.last_child(last_li)
&& let Some(id) = self.nodes[lch].tail.take()
{
let p = self.sub_element(last_li, Tag::P);
self.nodes[p].text = Some(id);
}
}
let li = self.sub_element(s, Tag::Li);
self.state.push(State::LooseList);
if let Some(first) = items.next() {
self.note_list(s, &first);
self.parse_blocks(li, VecDeque::from([first]));
}
self.state.pop();
s
}
_ if self.tag(parent) == Tag::List => parent,
_ => {
let created = self.sub_element(parent, Tag::List);
self.nodes[created].ordered = ordered;
created
}
};
self.state.push(State::List);
for item in items {
if item.first().is_some_and(|l| l.text.starts_with(" ")) {
if let Some(last) = self.last_child(lst) {
self.parse_blocks(last, VecDeque::from([item]));
}
} else {
self.note_list(lst, &item);
let li = self.sub_element(lst, Tag::Li);
self.parse_blocks(li, VecDeque::from([item]));
}
}
self.state.pop();
}
fn run_header(&mut self, parent: usize, blocks: &mut VecDeque<Block>, at: usize) {
let mut block = blocks.pop_front().unwrap_or_default();
let mut rest = block.split_off(at);
let after = rest.split_off(1);
if !block.is_empty() {
self.parse_blocks(parent, VecDeque::from([block]));
}
let id = self.fresh_id();
let heading = self.sub_element(parent, Tag::P);
self.nodes[heading].text = Some(id);
self.record(&rest, |_| Some(Leaf::Heading(id)));
if !after.is_empty() {
blocks.push_front(after);
}
}
fn run_paragraph(&mut self, parent: usize, blocks: &mut VecDeque<Block>) {
let block = blocks.pop_front().unwrap_or_default();
if !block.iter().any(|l| !is_blank(&l.text)) {
return;
}
let id = if self.is_state(State::List) {
match self.last_child(parent) {
Some(sibling) => match self.nodes[sibling].tail {
Some(id) => id,
None => {
let id = self.fresh_id();
self.nodes[sibling].tail = Some(id);
id
}
},
None => match self.nodes[parent].text {
Some(id) => id,
None => {
let id = self.fresh_id();
self.nodes[parent].text = Some(id);
id
}
},
}
} else {
let id = self.fresh_id();
let p = self.sub_element(parent, Tag::P);
self.nodes[p].text = Some(id);
id
};
self.record(&block, |l| (!is_blank(&l.text)).then_some(Leaf::Para(id)));
}
}
pub(crate) struct Source<'a> {
pub lines: &'a [&'a str],
pub fence_openers: &'a BTreeSet<usize>,
pub fence_inside: &'a BTreeSet<usize>,
}
fn expand_tabs(line: &str) -> String {
let mut out = String::with_capacity(line.len());
let mut col = 0;
for ch in line.chars() {
if ch == '\t' {
let pad = TAB - col % TAB;
out.extend(std::iter::repeat_n(' ', pad));
col += pad;
} else {
out.push(ch);
col += 1;
}
}
out
}
fn fence_prefix_len(text: &str) -> usize {
let mut at = leading_spaces(text);
loop {
let rest = &text[at..];
let digits = rest.bytes().take_while(u8::is_ascii_digit).count();
let marker = if digits > 0 && matches!(rest.as_bytes().get(digits), Some(b'.' | b')')) {
digits + 1
} else if matches!(rest.as_bytes().first(), Some(b'-' | b'*' | b'+')) {
1
} else {
return at;
};
let spaces = rest[marker..].bytes().take_while(|&b| b == b' ').count();
if spaces == 0 || rest[marker + spaces..].is_empty() {
return at;
}
at += marker + spaces;
}
}
fn preprocess(source: &Source<'_>) -> Vec<Ln> {
let mut out = Vec::with_capacity(source.lines.len());
for (idx, raw) in source.lines.iter().enumerate() {
if source.fence_inside.contains(&idx) {
continue;
}
let mut text = expand_tabs(raw.trim_end_matches('\r'));
if source.fence_openers.contains(&idx) {
text = format!("{}\u{2}fence\u{3}", &text[..fence_prefix_len(&text)]);
} else if idx > 0 && text.bytes().all(|b| b == b' ') {
text.clear();
}
out.push(Ln { idx, text });
}
out
}
struct LineModel {
kinds: Vec<Vec<Leaf>>,
code_text: Vec<Option<String>>,
item_depth: Vec<Option<usize>>,
line_list: Vec<Option<(usize, bool)>>,
}
impl Parser {
fn leaf_depths(&self) -> (HashMap<usize, usize>, HashMap<usize, usize>) {
let mut paras = HashMap::new();
let mut codes = HashMap::new();
let mut stack = vec![(0usize, 0usize)];
while let Some((node, outer)) = stack.pop() {
let depth = outer + usize::from(self.nodes[node].tag == Tag::Li);
if let Some(id) = self.nodes[node].text {
paras.insert(id, depth);
}
for &child in &self.nodes[node].children {
if self.nodes[child].tag == Tag::Pre {
codes.insert(child, depth);
} else {
stack.push((child, depth));
}
if let Some(id) = self.nodes[child].tail {
paras.insert(id, depth);
}
}
}
(paras, codes)
}
}
fn line_model(source: &Source<'_>) -> Option<LineModel> {
let lines = preprocess(source);
let work: usize = lines
.iter()
.map(|l| {
let indent = leading_spaces(&l.text);
indent * indent / 4
})
.sum();
if work > MAX_INDENT_WORK {
return None;
}
let mut parser = Parser::new(source.lines.len());
let blocks = split_blocks(&lines);
parser.parse_blocks(0, blocks);
if parser.overrun {
return None;
}
let (paras, codes) = parser.leaf_depths();
let item_depth = parser
.kinds
.iter()
.map(|leaves| {
leaves.iter().rev().find_map(|leaf| match leaf {
Leaf::Marker => None,
Leaf::Para(id) | Leaf::Heading(id) => paras.get(id).copied(),
Leaf::Code(id) => codes.get(id).copied(),
})
})
.collect();
Some(LineModel {
kinds: parser.kinds,
code_text: parser.code_text,
item_depth,
line_list: parser.line_list,
})
}
pub(crate) fn marker_lines(source: &Source<'_>) -> Option<Vec<bool>> {
Some(
line_model(source)?
.kinds
.iter()
.map(|leaves| leaves.contains(&Leaf::Marker))
.collect(),
)
}
fn regions(source: &Source<'_>) -> Vec<usize> {
let lines = preprocess(source);
let mut region_of = vec![0; source.lines.len()];
let mut starts: Vec<usize> = Vec::new();
for block in split_blocks(&lines) {
let Some(first) = block.first() else { continue };
let boundary =
!first.text.is_empty() && !first.text.starts_with(" ") && !olist_test(&block) && !ulist_test(&block);
if boundary {
starts.push(first.idx);
}
}
let mut region = 0;
let mut next = 0;
for (idx, slot) in region_of.iter_mut().enumerate() {
while next < starts.len() && starts[next] <= idx {
next += 1;
region += 1;
}
*slot = region;
}
region_of
}
fn canonical_lists(line_list: &[Option<(usize, bool)>], region_of: &[usize]) -> Vec<Option<(usize, bool)>> {
let mut maps: HashMap<(usize, usize), usize> = HashMap::new();
let mut counters: HashMap<usize, usize> = HashMap::new();
line_list
.iter()
.enumerate()
.map(|(idx, entry)| {
entry.map(|(list, ordered)| {
let region = region_of[idx];
let counter = counters.entry(region).or_insert(0);
let n = *maps.entry((region, list)).or_insert_with(|| {
*counter += 1;
*counter
});
(n, ordered)
})
})
.collect()
}
fn canonical(kinds: &[Vec<Leaf>], region_of: &[usize]) -> Vec<Vec<Leaf>> {
let mut maps: HashMap<(usize, u8, usize), usize> = HashMap::new();
let mut counters: HashMap<usize, usize> = HashMap::new();
kinds
.iter()
.enumerate()
.map(|(idx, leaves)| {
let region = region_of[idx];
leaves
.iter()
.map(|leaf| match *leaf {
Leaf::Marker => Leaf::Marker,
Leaf::Para(id) | Leaf::Code(id) | Leaf::Heading(id) => {
let kind = match leaf {
Leaf::Para(_) => 0,
Leaf::Code(_) => 1,
_ => 2,
};
let counter = counters.entry(region).or_insert(0);
let n = *maps.entry((region, kind, id)).or_insert_with(|| {
*counter += 1;
*counter
});
match kind {
0 => Leaf::Para(n),
1 => Leaf::Code(n),
_ => Leaf::Heading(n),
}
}
})
.collect()
})
.collect()
}
pub(crate) fn differing_regions(
before: &Source<'_>,
after: &Source<'_>,
owner_of_line: &HashMap<usize, usize>,
) -> Option<BTreeSet<usize>> {
if before.lines.len() != after.lines.len() {
return None;
}
let region_of = regions(before);
let (old, new) = (line_model(before)?, line_model(after)?);
let a = canonical(&old.kinds, ®ion_of);
let b = canonical(&new.kinds, ®ion_of);
let markers: Vec<(usize, usize)> = (0..new.kinds.len())
.filter_map(|j| {
let count = new.kinds[j].iter().filter(|k| **k == Leaf::Marker).count();
(count > 0 && new.item_depth[j].is_some()).then_some((j, count))
})
.collect();
let mut opening_cache: HashMap<(usize, usize), Option<usize>> = HashMap::new();
let mut opening_marker = |at: usize, depth: usize| {
let before = markers.partition_point(|&(j, _)| j < at);
*opening_cache.entry((before, depth)).or_insert_with(|| {
markers[..before]
.iter()
.rev()
.find(|&&(j, count)| new.item_depth[j].is_some_and(|d| d >= depth && d + 1 - count <= depth))
.map(|&(j, _)| j)
})
};
let mut moved_across_a_list = |i: usize| match (old.item_depth[i], new.item_depth[i]) {
(Some(0), Some(to)) if to > 0 => owner_of_line
.get(&i)
.is_none_or(|&owner| opening_marker(i, to) != Some(owner)),
(Some(from), Some(to)) => to < from,
_ => false,
};
let lists_before = canonical_lists(&old.line_list, ®ion_of);
let lists_after = canonical_lists(&new.line_list, ®ion_of);
let mut differing: BTreeSet<usize> = (0..a.len())
.filter(|&i| {
a[i] != b[i]
|| old.code_text[i] != new.code_text[i]
|| lists_before[i] != lists_after[i]
|| moved_across_a_list(i)
})
.map(|i| region_of[i])
.collect();
differing.extend(unmodeled_regions(before, ®ion_of));
Some(differing)
}
fn is_unmodeled(line: &str) -> bool {
let t = line.trim_start();
t.starts_with('>')
|| t.starts_with("*[")
|| t.starts_with("!!!")
|| t.starts_with("???")
|| t.starts_with(": ")
|| t.contains('|')
|| is_reference_definition(t)
|| is_thematic_break(t)
|| is_setext_underline(t)
|| t.starts_with('<')
}
fn is_reference_definition(trimmed: &str) -> bool {
trimmed.starts_with('[') && trimmed.contains("]:")
}
fn is_thematic_break(trimmed: &str) -> bool {
let Some(mark @ ('-' | '*' | '_')) = trimmed.chars().next() else {
return false;
};
trimmed.chars().all(|c| c == mark || c == ' ') && trimmed.chars().filter(|&c| c == mark).count() >= 3
}
fn flush_with_opener(source: &Source<'_>, idx: usize) -> bool {
let opener = source.fence_openers.range(..idx).next_back();
opener.is_none_or(|&o| {
leading_spaces(&expand_tabs(source.lines[idx])) == leading_spaces(&expand_tabs(source.lines[o]))
})
}
fn is_setext_underline(trimmed: &str) -> bool {
let t = trimmed.trim_end();
(!t.is_empty() && t.chars().all(|c| c == '=')) || (t.len() >= 2 && t.chars().all(|c| c == '-'))
}
fn is_structural_inside_fence(line: &str) -> bool {
let t = line.trim_start();
is_unmodeled(line)
|| t.is_empty()
|| t.starts_with('#')
|| t.starts_with('>')
|| t.starts_with("- ")
|| t.starts_with("* ")
|| t.starts_with("+ ")
|| t.chars().next().is_some_and(|c| c.is_ascii_digit())
}
fn is_lone_hyphen_under_text(line: &str, above: &str) -> bool {
line.trim() == "-" && !above.trim().is_empty()
}
fn has_exotic_space(line: &str) -> bool {
line.chars()
.any(|c| c.is_whitespace() && !matches!(c, ' ' | '\t' | '\r'))
}
fn has_inner_tab(line: &str) -> bool {
line.trim_start().contains('\t')
}
fn has_unpaired_backticks(line: &str) -> bool {
let mut runs: HashMap<usize, usize> = HashMap::new();
let mut length = 0;
for c in line.chars().chain(std::iter::once(' ')) {
if c == '`' {
length += 1;
} else if length > 0 {
*runs.entry(length).or_default() += 1;
length = 0;
}
}
runs.len() > 1 || runs.values().any(|count| count % 2 == 1)
}
fn unmodeled_regions(source: &Source<'_>, region_of: &[usize]) -> BTreeSet<usize> {
let mut first: HashMap<usize, usize> = HashMap::new();
for (idx, ®ion) in region_of.iter().enumerate() {
first.entry(region).or_insert(idx);
}
let mut has_list: HashSet<usize> = HashSet::new();
for (idx, ®ion) in region_of.iter().enumerate() {
if child_match(source.lines[idx].trim_start()).is_some() {
has_list.insert(region);
}
}
let quoted_start = |region: usize| {
!has_list.contains(®ion)
&& first
.get(®ion)
.is_some_and(|&i| source.lines[i].trim_start().starts_with('>'))
};
(0..source.lines.len())
.filter(|&idx| {
if has_exotic_space(source.lines[idx]) {
true
} else if source.fence_inside.contains(&idx) {
is_structural_inside_fence(source.lines[idx])
|| !flush_with_opener(source, idx)
|| has_inner_tab(source.lines[idx])
} else if source.fence_openers.contains(&idx) {
false
} else {
(is_unmodeled(source.lines[idx]) && !quoted_start(region_of[idx]))
|| has_unpaired_backticks(source.lines[idx])
|| has_inner_tab(source.lines[idx])
|| (idx > 0 && is_lone_hyphen_under_text(source.lines[idx], source.lines[idx - 1]))
}
})
.map(|idx| region_of[idx])
.collect()
}
pub(crate) fn line_regions(source: &Source<'_>) -> Vec<usize> {
regions(source)
}
#[cfg(test)]
mod tests {
use super::*;
fn leaves(text: &str) -> Vec<Vec<Leaf>> {
let lines: Vec<&str> = text.split('\n').collect();
let none = BTreeSet::new();
let source = Source {
lines: &lines,
fence_openers: &none,
fence_inside: &none,
};
canonical(&line_model(&source).unwrap().kinds, &vec![0; lines.len()])
}
fn kinds(text: &str) -> String {
leaves(text)
.iter()
.map(|l| {
l.iter()
.map(|k| match k {
Leaf::Marker => 'M',
Leaf::Para(_) => 'P',
Leaf::Code(_) => 'C',
Leaf::Heading(_) => 'H',
})
.collect::<String>()
})
.collect::<Vec<_>>()
.join(",")
}
#[test]
fn a_heading_ends_the_paragraph_before_a_list() {
assert_eq!(kinds("# Heading\n- a\n\n text"), "H,MP,,P");
assert_eq!(kinds("para\n# Heading\n- a"), "P,H,MP");
}
#[test]
fn a_document_indented_too_deeply_to_model_in_bounded_time_is_not_modeled() {
let text: String = (0..1200).map(|i| " ".repeat(i) + "- x\n").collect();
let lines: Vec<&str> = text.split('\n').collect();
let none = BTreeSet::new();
let source = Source {
lines: &lines,
fence_openers: &none,
fence_inside: &none,
};
assert!(marker_lines(&source).is_none());
}
#[test]
fn nested_item_continuation_is_a_paragraph() {
assert_eq!(kinds("- a\n - b\n\n c"), "MP,MP,,P");
assert_eq!(kinds("- a\n - b\n\n c"), "MP,MP,,C");
}
#[test]
fn chained_item_with_a_text_marker_between_reads_text_as_code() {
assert_eq!(kinds("- - a\n\n 1) b\n\n text"), "MMP,,P,,P");
}
#[test]
fn a_marker_indented_past_eight_columns_is_paragraph_text() {
assert_eq!(kinds("- a\n - b"), "MP,MP");
assert_eq!(kinds("- a\n - b"), "MP,P");
assert_eq!(kinds("- b\n - c"), "MP,P");
}
#[test]
fn a_paragraph_and_a_code_block_are_numbered_apart() {
let numbered = leaves(" - a\n\n * a\n 10. word two\n - - text\n");
assert_eq!(
numbered,
vec![
vec![Leaf::Marker, Leaf::Para(1)],
vec![],
vec![Leaf::Code(2)],
vec![Leaf::Marker, Leaf::Para(3)],
vec![Leaf::Marker, Leaf::Marker, Leaf::Para(4)],
vec![],
]
);
}
#[test]
fn code_without_an_open_item() {
assert_eq!(kinds("text\n\n code"), "P,,C");
}
#[test]
fn moving_a_paragraph_between_owners_keeps_its_kind() {
assert_eq!(kinds("- a\n - b\n\n c"), kinds("- a\n - b\n\n c"));
}
#[test]
fn turning_prose_into_code_is_a_difference() {
let before: Vec<&str> = "- - a\n\n 1) b\n\n text".split('\n').collect();
let after: Vec<&str> = "- - a\n\n 1) b\n\n text".split('\n').collect();
let none = BTreeSet::new();
let b = Source {
lines: &before,
fence_openers: &none,
fence_inside: &none,
};
let a = Source {
lines: &after,
fence_openers: &none,
fence_inside: &none,
};
assert_eq!(differing_regions(&b, &a, &HashMap::new()), Some(BTreeSet::from([0])));
}
fn differing(before: &str, after: &str) -> Option<BTreeSet<usize>> {
differing_owned(before, after, &HashMap::new())
}
fn differing_owned(before: &str, after: &str, owners: &HashMap<usize, usize>) -> Option<BTreeSet<usize>> {
let before: Vec<&str> = before.split('\n').collect();
let after: Vec<&str> = after.split('\n').collect();
let none = BTreeSet::new();
let b = Source {
lines: &before,
fence_openers: &none,
fence_inside: &none,
};
let a = Source {
lines: &after,
fence_openers: &none,
fence_inside: &none,
};
differing_regions(&b, &a, owners)
}
#[test]
fn a_paragraph_joining_a_list_is_accepted_only_under_its_owning_item() {
let (before, after) = ("- a\n\n text", "- a\n\n text");
assert_eq!(
differing_owned(before, after, &HashMap::from([(2, 0)])),
Some(BTreeSet::new())
);
assert_eq!(
differing_owned(before, after, &HashMap::from([(2, 1)])).map(|r| r.len()),
Some(1)
);
assert_eq!(
differing_owned(
"- - a\n\n 1) b\n\n text",
"- - a\n\n 1) b\n\n text",
&HashMap::from([(4, 2)])
)
.map(|r| r.len()),
Some(1)
);
}
#[test]
fn a_paragraph_joining_a_list_from_outside_it_is_a_difference() {
assert_eq!(
differing("- a\n\n text", "- a\n\n text").map(|regions| regions.len()),
Some(1)
);
}
#[test]
fn a_paragraph_moving_deeper_inside_its_list_is_not_a_difference() {
assert_eq!(
differing("- a\n - b\n\n text", "- a\n - b\n\n text"),
Some(BTreeSet::new())
);
}
}