use super::code::{self, Cont, Lang};
use crate::text::buffer::Buffer;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Marker {
Dash,
Star,
Plus,
Ordered,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum BlockKind {
Heading(u8),
FenceOpen(String),
FenceBody { lang: Option<Lang>, cont: Cont },
FenceClose,
FrontMatter,
Quote(u8),
ListItem {
depth: u8,
marker: Marker,
checked: Option<bool>,
},
Table,
Rule,
Blank,
Paragraph,
}
#[derive(Clone, Debug, PartialEq, Eq, Default)]
pub enum Carry {
#[default]
None,
InFence { fence: String, lang: Option<Lang>, cont: Cont },
InFrontMatter,
InTable,
}
pub struct BlockCache {
pub kinds: Vec<BlockKind>,
pub carry: Vec<Carry>,
pub revision: u64,
}
impl BlockCache {
pub fn build(buffer: &Buffer) -> Self {
let n = buffer.line_count();
let mut kinds = Vec::with_capacity(n);
let mut carry = Vec::with_capacity(n);
let mut cur = Carry::None;
for i in 0..n {
carry.push(cur.clone());
let line = buffer.line_text(i);
let (kind, next) = classify(&line, &cur, i == 0);
kinds.push(kind);
promote_table_header(&mut kinds, buffer, i, &cur);
cur = next;
}
BlockCache {
kinds,
carry,
revision: buffer.revision,
}
}
pub fn invalidate_from(&mut self, buffer: &Buffer, edited: usize) -> usize {
let n = buffer.line_count();
let edited = edited.min(n.saturating_sub(1));
let start = edited.saturating_sub(1);
if start == 0 || start >= self.carry.len() {
*self = Self::build(buffer);
return n.saturating_sub(1);
}
let can_early_stop = n == self.kinds.len();
let mut cur = self.carry[start].clone();
self.kinds.resize(n, BlockKind::Paragraph);
self.carry.resize(n, Carry::None);
let mut last = start;
let mut i = start;
while i < n {
let old_next = if i + 1 < n {
Some(self.carry[i + 1].clone())
} else {
None
};
self.carry[i] = cur.clone();
let line = buffer.line_text(i);
let (kind, next) = classify(&line, &cur, i == 0);
self.kinds[i] = kind;
promote_table_header(&mut self.kinds, buffer, i, &cur);
last = i;
let opens_table = i + 1 < n
&& self.kinds[i + 1] == BlockKind::Table
&& self.carry[i + 1] != Carry::InTable;
if can_early_stop && i >= edited && !opens_table {
if let Some(old) = old_next {
if next == old {
break;
}
}
}
cur = next;
i += 1;
}
self.revision = buffer.revision;
last
}
}
pub fn classify(line: &str, carry: &Carry, is_file_head: bool) -> (BlockKind, Carry) {
match carry {
Carry::InFrontMatter => {
let leaving = if closes_front_matter(line) {
Carry::None
} else {
Carry::InFrontMatter
};
return (BlockKind::FrontMatter, leaving);
}
Carry::InFence { fence, lang, cont } => {
if closes_fence(line, fence) {
return (BlockKind::FenceClose, Carry::None);
}
let next = match lang {
Some(lang) => code::scan(line, *lang, *cont, &mut Vec::new()),
None => Cont::None,
};
return (
BlockKind::FenceBody { lang: *lang, cont: *cont },
Carry::InFence { fence: fence.clone(), lang: *lang, cont: next },
);
}
Carry::InTable => {
if has_cell_separator(line) {
return (BlockKind::Table, Carry::InTable);
}
}
Carry::None => {}
}
let indent = leading_ws_cols(line);
if let Some(fence) = fence_opener(line) {
let info = line.trim_start()[fence.len()..].trim().to_string();
let lang = Lang::from_info(&info);
return (
BlockKind::FenceOpen(info),
Carry::InFence { fence, lang, cont: Cont::None },
);
}
if is_file_head && line.trim_end() == "---" {
return (BlockKind::FrontMatter, Carry::InFrontMatter);
}
let body = line.trim();
if body.is_empty() {
return (BlockKind::Blank, Carry::None);
}
if is_table_delimiter(body) {
return (BlockKind::Table, Carry::InTable);
}
if indent < 4 {
if is_thematic_break(body) {
return (BlockKind::Rule, Carry::None);
}
if let Some(level) = atx_heading_level(line.trim_start()) {
return (BlockKind::Heading(level), Carry::None);
}
}
if line.trim_start().starts_with('>') {
return (BlockKind::Quote(quote_depth(line.trim_start())), Carry::None);
}
if let Some((marker, checked)) = list_marker(line.trim_start()) {
return (
BlockKind::ListItem {
depth: indent.min(u8::MAX as usize) as u8,
marker,
checked,
},
Carry::None,
);
}
(BlockKind::Paragraph, Carry::None)
}
fn leading_ws_cols(line: &str) -> usize {
line.chars().take_while(|c| *c == ' ' || *c == '\t').count()
}
fn fence_opener(line: &str) -> Option<String> {
if leading_ws_cols(line) >= 4 {
return None;
}
let s = line.trim_start();
let ch = s.chars().next()?;
if ch != '`' && ch != '~' {
return None;
}
let run = s.chars().take_while(|c| *c == ch).count();
if run < 3 {
return None;
}
if ch == '`' && s[run..].contains('`') {
return None;
}
Some(std::iter::repeat_n(ch, run).collect())
}
fn closes_fence(line: &str, open: &str) -> bool {
let ch = match open.chars().next() {
Some(c) => c,
None => return false,
};
let s = line.trim();
!s.is_empty()
&& s.chars().all(|c| c == ch)
&& s.chars().count() >= open.chars().count()
}
fn closes_front_matter(line: &str) -> bool {
matches!(line.trim_end(), "---" | "...")
}
fn atx_heading_level(s: &str) -> Option<u8> {
let hashes = s.chars().take_while(|c| *c == '#').count();
if hashes == 0 || hashes > 6 {
return None;
}
match s[hashes..].chars().next() {
None => Some(hashes as u8),
Some(' ') | Some('\t') => Some(hashes as u8),
_ => None,
}
}
fn promote_table_header(kinds: &mut [BlockKind], buffer: &Buffer, i: usize, entering: &Carry) {
if i == 0 || kinds[i] != BlockKind::Table || *entering == Carry::InTable {
return;
}
if has_cell_separator(&buffer.line_text(i - 1)) {
kinds[i - 1] = BlockKind::Table;
}
}
pub fn has_cell_separator(line: &str) -> bool {
let mut escaped = false;
for c in line.chars() {
match c {
'\\' => escaped = !escaped,
'|' if !escaped => return true,
_ => escaped = false,
}
}
false
}
fn is_table_delimiter(body: &str) -> bool {
let mut dashes = false;
let mut bars = false;
for c in body.chars() {
match c {
'-' => dashes = true,
'|' => bars = true,
':' | ' ' | '\t' => {}
_ => return false,
}
}
dashes && bars
}
fn is_thematic_break(body: &str) -> bool {
let mut ch = None;
let mut count = 0;
for c in body.chars() {
match c {
' ' | '\t' => {}
'-' | '*' | '_' => {
match ch {
Some(x) if x != c => return false,
_ => {}
}
ch = Some(c);
count += 1;
}
_ => return false,
}
}
count >= 3
}
fn quote_depth(s: &str) -> u8 {
let mut depth = 0u8;
let mut chars = s.chars().peekable();
loop {
match chars.peek() {
Some('>') => {
chars.next();
depth = depth.saturating_add(1);
if chars.peek() == Some(&' ') {
chars.next();
}
}
Some(' ') => {
chars.next();
}
_ => break,
}
}
depth
}
fn list_marker(s: &str) -> Option<(Marker, Option<bool>)> {
let mut chars = s.chars();
let first = chars.next()?;
let (marker, rest_at) = match first {
'-' => (Marker::Dash, 1),
'*' => (Marker::Star, 1),
'+' => (Marker::Plus, 1),
d if d.is_ascii_digit() => {
let digits = s.chars().take_while(|c| c.is_ascii_digit()).count();
let delim = s.chars().nth(digits);
if !matches!(delim, Some('.') | Some(')')) {
return None;
}
(Marker::Ordered, digits + 1)
}
_ => return None,
};
match s.chars().nth(rest_at) {
None => return Some((marker, None)),
Some(' ') | Some('\t') => {}
_ => return None,
}
let after: String = s.chars().skip(rest_at + 1).collect();
let checked = task_checkbox(&after);
Some((marker, checked))
}
fn task_checkbox(after_marker: &str) -> Option<bool> {
let mut chars = after_marker.chars();
if chars.next()? != '[' {
return None;
}
let inner = chars.next()?;
if chars.next()? != ']' {
return None;
}
match chars.next() {
None | Some(' ') | Some('\t') => {}
_ => return None,
}
match inner {
' ' => Some(false),
'x' | 'X' => Some(true),
_ => None,
}
}
impl BlockKind {
pub fn allows_inline(&self) -> bool {
!matches!(
self,
BlockKind::FenceOpen(_)
| BlockKind::FenceBody { .. }
| BlockKind::FenceClose
| BlockKind::FrontMatter
| BlockKind::Table
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::text::buffer::Buffer;
use crate::text::cursor::Cursor;
fn kind(line: &str) -> BlockKind {
classify(line, &Carry::None, false).0
}
fn buf(text: &str) -> Buffer {
let mut b = Buffer::empty();
b.insert_str(Cursor::new(0, 0), text);
b
}
#[test]
fn tables_classify_header_delimiter_and_body() {
let b = buf("intro\n| a | b |\n|---|---|\n| 1 | 2 |\n\nafter\n");
let cache = BlockCache::build(&b);
assert_eq!(cache.kinds[0], BlockKind::Paragraph, "prose above is untouched");
assert_eq!(cache.kinds[1], BlockKind::Table, "header row promoted");
assert_eq!(cache.kinds[2], BlockKind::Table, "delimiter row");
assert_eq!(cache.kinds[3], BlockKind::Table, "body row");
assert_eq!(cache.kinds[4], BlockKind::Blank, "a blank line ends the table");
assert_eq!(cache.kinds[5], BlockKind::Paragraph);
}
#[test]
fn a_rule_is_not_a_table_delimiter() {
assert_eq!(kind("---"), BlockKind::Rule);
assert_eq!(kind("| --- | --- |"), BlockKind::Table);
}
#[test]
fn editing_the_header_row_keeps_it_a_header() {
let mut b = buf("intro\n\n| a | b |\n|---|---|\n| 1 | 2 |\n");
let mut cache = BlockCache::build(&b);
assert_eq!(cache.kinds[2], BlockKind::Table);
b.insert_str(Cursor::new(2, 3), "x");
cache.invalidate_from(&b, 2);
assert_eq!(cache.kinds[2], BlockKind::Table, "header row stays a header");
assert_eq!(cache.kinds, BlockCache::build(&b).kinds);
}
#[test]
fn deleting_the_delimiter_row_demotes_the_header() {
let mut b = buf("| a | b |\n|---|---|\n| 1 | 2 |\n");
let mut cache = BlockCache::build(&b);
assert_eq!(cache.kinds[0], BlockKind::Table);
let start = b.rope.line_to_char(1);
let end = b.rope.line_to_char(2);
b.delete_chars(start, end);
cache.invalidate_from(&b, 0);
assert_eq!(cache.kinds, BlockCache::build(&b).kinds);
assert_eq!(cache.kinds[0], BlockKind::Paragraph, "no delimiter, no table");
}
#[test]
fn headings() {
assert_eq!(kind("# Title"), BlockKind::Heading(1));
assert_eq!(kind("###### deep"), BlockKind::Heading(6));
assert_eq!(kind("#"), BlockKind::Heading(1));
assert_eq!(kind("####### too deep"), BlockKind::Paragraph);
assert_eq!(kind("#no-space"), BlockKind::Paragraph);
}
#[test]
fn lists_and_tasks() {
assert!(matches!(
kind("- item"),
BlockKind::ListItem { marker: Marker::Dash, checked: None, .. }
));
assert!(matches!(
kind("* item"),
BlockKind::ListItem { marker: Marker::Star, .. }
));
assert!(matches!(
kind("1. item"),
BlockKind::ListItem { marker: Marker::Ordered, .. }
));
assert!(matches!(
kind("12) item"),
BlockKind::ListItem { marker: Marker::Ordered, .. }
));
assert!(matches!(
kind("- [ ] todo"),
BlockKind::ListItem { checked: Some(false), .. }
));
assert!(matches!(
kind("- [x] done"),
BlockKind::ListItem { checked: Some(true), .. }
));
assert!(matches!(
kind(" - deep"),
BlockKind::ListItem { depth: 4, .. }
));
}
#[test]
fn rules_beat_lists() {
assert_eq!(kind("---"), BlockKind::Rule);
assert_eq!(kind("***"), BlockKind::Rule);
assert_eq!(kind("___"), BlockKind::Rule);
assert_eq!(kind("- - -"), BlockKind::Rule);
assert_eq!(kind("* * *"), BlockKind::Rule);
assert_eq!(kind("-*-"), BlockKind::Paragraph);
}
#[test]
fn quotes() {
assert_eq!(kind("> a"), BlockKind::Quote(1));
assert_eq!(kind(">> a"), BlockKind::Quote(2));
assert_eq!(kind("> > a"), BlockKind::Quote(2));
}
#[test]
fn blank_and_paragraph() {
assert_eq!(kind(""), BlockKind::Blank);
assert_eq!(kind(" "), BlockKind::Blank);
assert_eq!(kind("just prose"), BlockKind::Paragraph);
assert_eq!(kind(" # not a heading"), BlockKind::Paragraph);
}
#[test]
fn fenced_code_spans_lines() {
let b = buf("```rust\nlet x = 1;\n```\nafter\n");
let cache = BlockCache::build(&b);
assert_eq!(cache.kinds[0], BlockKind::FenceOpen("rust".into()));
assert!(matches!(cache.kinds[1], BlockKind::FenceBody { .. }));
assert_eq!(cache.kinds[2], BlockKind::FenceClose);
assert_eq!(cache.kinds[3], BlockKind::Paragraph);
let b2 = buf("```\n# not a heading\n```\n");
let c2 = BlockCache::build(&b2);
assert!(matches!(c2.kinds[1], BlockKind::FenceBody { .. }));
}
#[test]
fn front_matter_only_at_head() {
let b = buf("---\ntitle: x\n---\n# Real heading\n");
let cache = BlockCache::build(&b);
assert_eq!(cache.kinds[0], BlockKind::FrontMatter);
assert_eq!(cache.kinds[1], BlockKind::FrontMatter);
assert_eq!(cache.kinds[2], BlockKind::FrontMatter);
assert_eq!(cache.kinds[3], BlockKind::Heading(1));
}
#[test]
fn incremental_invalidation_matches_full_rebuild() {
let mut b = buf("# Title\n\nsome prose\n- a list\n> a quote\n");
let mut cache = BlockCache::build(&b);
b.insert_str(Cursor::new(2, 0), "## ");
cache.invalidate_from(&b, 2);
let fresh = BlockCache::build(&b);
assert_eq!(cache.kinds, fresh.kinds);
assert_eq!(cache.carry, fresh.carry);
assert_eq!(cache.kinds[2], BlockKind::Heading(2));
}
#[test]
fn an_edit_is_reclassified_even_when_the_line_above_is_unchanged() {
let mut b = buf("intro\n\nplain para\n\ntail\n");
let mut cache = BlockCache::build(&b);
assert_eq!(cache.kinds[2], BlockKind::Paragraph);
b.insert_str(Cursor::new(2, 0), "- ");
cache.invalidate_from(&b, 2);
assert!(
matches!(cache.kinds[2], BlockKind::ListItem { .. }),
"typing `- ` makes the line a list item: {:?}",
cache.kinds[2]
);
assert_eq!(cache.kinds, BlockCache::build(&b).kinds);
}
#[test]
fn incremental_invalidation_across_a_fence_edit() {
let mut b = buf("prose\ncode line\nmore\n");
let mut cache = BlockCache::build(&b);
assert_eq!(cache.kinds[1], BlockKind::Paragraph);
b.insert_str(Cursor::new(0, 0), "```\n");
cache.invalidate_from(&b, 0);
let fresh = BlockCache::build(&b);
assert_eq!(cache.kinds, fresh.kinds);
assert_eq!(cache.kinds[0], BlockKind::FenceOpen(String::new()));
assert!(matches!(cache.kinds[1], BlockKind::FenceBody { .. }));
}
}