use std::ops::Range;
use pulldown_cmark::HeadingLevel;
use super::segment_break;
#[derive(Clone, Default, PartialEq, Eq, Hash)]
pub struct SrcLines {
pub first: u32,
cols: Cols,
}
#[derive(Clone, PartialEq, Eq, Hash)]
enum Cols {
Uniform {
col: u32,
len: u32,
chrome: [u32; 2],
},
Varied(Box<[Option<u32>]>),
}
impl Cols {
const NO_LINE: u32 = u32::MAX;
}
impl Default for Cols {
fn default() -> Self {
Cols::Uniform {
col: 0,
len: 0,
chrome: [Cols::NO_LINE; 2],
}
}
}
impl SrcLines {
pub fn new(first: u32, cols: &[Option<u32>]) -> Self {
let mut col = None;
let mut chrome = [Cols::NO_LINE; 2];
let mut n_chrome = 0;
let mut uniform = true;
for (k, c) in cols.iter().enumerate() {
match *c {
None if n_chrome < 2 => {
chrome[n_chrome] = to_u32(k);
n_chrome += 1;
}
Some(c) if col.is_none_or(|col| col == c) => col = Some(c),
_ => {
uniform = false;
break;
}
}
}
let cols = if uniform {
Cols::Uniform {
col: col.unwrap_or(0),
len: to_u32(cols.len()),
chrome,
}
} else {
Cols::Varied(cols.into())
};
Self { first, cols }
}
pub fn len(&self) -> usize {
match &self.cols {
Cols::Uniform { len, .. } => *len as usize,
Cols::Varied(cols) => cols.len(),
}
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
pub fn col(&self, k: usize) -> Option<u32> {
match &self.cols {
Cols::Uniform { col, len, chrome } => {
let k = u32::try_from(k).ok().filter(|&k| k < *len)?;
(!chrome.contains(&k)).then_some(*col)
}
Cols::Varied(cols) => cols.get(k).copied().flatten(),
}
}
pub fn cols(&self) -> impl ExactSizeIterator<Item = Option<u32>> + '_ {
(0..self.len()).map(|k| self.col(k))
}
pub fn span(&self) -> Range<u32> {
self.first..self.first.saturating_add(to_u32(self.len()))
}
}
impl std::fmt::Debug for SrcLines {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SrcLines")
.field("first", &self.first)
.field("content_col", &self.cols().collect::<Vec<_>>())
.finish()
}
}
pub(crate) fn to_u32(n: usize) -> u32 {
u32::try_from(n).unwrap_or(u32::MAX)
}
pub type LineSpan = Range<u32>;
#[allow(clippy::enum_variant_names)]
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum Block {
Heading {
level: HeadingLevel,
inlines: Vec<Inline>,
src: SrcLines,
},
Paragraph {
inlines: Vec<Inline>,
src: SrcLines,
},
CodeBlock {
language: Option<String>,
content: String,
fenced: bool,
src: SrcLines,
},
BlockQuote {
blocks: Vec<Block>,
span: LineSpan,
hidden: Vec<u32>,
},
List {
ordered: bool,
start: Option<u64>,
items: Vec<ListItem>,
span: LineSpan,
},
HorizontalRule {
src: SrcLines,
},
Table {
col_count: usize,
headers: Vec<Vec<Inline>>,
rows: Vec<Vec<Vec<Inline>>>,
user_widths: Option<Vec<Option<usize>>>,
src: SrcLines,
},
Html(String, SrcLines),
HtmlComment(String, SrcLines),
ImageBlock {
alt: String,
url: String,
src: SrcLines,
},
MetadataBlock {
kind: MetadataKind,
content: String,
src: SrcLines,
},
FootnoteDefinition {
label: String,
blocks: Vec<Block>,
span: LineSpan,
hidden: Vec<u32>,
},
}
impl Block {
pub fn src(&self) -> Option<&SrcLines> {
match self {
Block::Heading { src, .. }
| Block::Paragraph { src, .. }
| Block::CodeBlock { src, .. }
| Block::HorizontalRule { src }
| Block::Table { src, .. }
| Block::Html(_, src)
| Block::HtmlComment(_, src)
| Block::ImageBlock { src, .. }
| Block::MetadataBlock { src, .. } => Some(src),
Block::BlockQuote { .. } | Block::List { .. } | Block::FootnoteDefinition { .. } => {
None
}
}
}
pub fn is_setext_heading(&self) -> bool {
matches!(self, Block::Heading { src, .. } if src.len() >= 2)
}
pub fn span(&self) -> LineSpan {
match self {
Block::BlockQuote { span, .. }
| Block::List { span, .. }
| Block::FootnoteDefinition { span, .. } => span.clone(),
leaf => leaf.src().map(SrcLines::span).unwrap_or_default(),
}
}
pub fn items_holding(&self, line: u32) -> Option<Vec<&ListItem>> {
let mut items = Vec::new();
let mut blocks = std::slice::from_ref(self);
while let Some(holder) = blocks.iter().find(|b| b.span().contains(&line)) {
match holder {
Block::List { items: list, .. } => {
let Some(item) = list.iter().find(|i| i.span.contains(&line)) else {
break;
};
items.push(item);
blocks = &item.blocks;
}
Block::BlockQuote { .. } | Block::FootnoteDefinition { .. } => return None,
_ => break,
}
}
Some(items)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum MetadataKind {
Yaml,
Toml,
}
impl MetadataKind {
pub fn delimiter(self) -> &'static str {
match self {
MetadataKind::Yaml => "---",
MetadataKind::Toml => "+++",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct ListItem {
pub blocks: Vec<Block>,
pub task: Option<bool>,
pub span: LineSpan,
pub hidden: Vec<u32>,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum Inline {
Text(String),
Bold(Vec<Inline>),
Italic(Vec<Inline>),
Strikethrough(Vec<Inline>),
Code(String),
Link {
text: Vec<Inline>,
url: String,
title: Option<String>,
},
Image {
alt: String,
url: String,
},
Highlight(Vec<Inline>),
HtmlComment(String),
Html(String),
FootnoteReference {
label: String,
},
Math {
source: String,
display: bool,
},
SoftBreak,
HardBreak,
}
pub fn heading_plain_text(inlines: &[Inline]) -> String {
inlines_to_plain(inlines).replace('\n', " ")
}
pub fn inlines_to_plain(inlines: &[Inline]) -> String {
let mut out = String::new();
let mut soft_breaks = Vec::new();
push_plain(inlines, false, &mut out, &mut soft_breaks);
for at in soft_breaks.into_iter().rev() {
let before = out[..at].chars().next_back();
let after = out[at + 1..].chars().next();
if before
.zip(after)
.is_some_and(|(b, a)| segment_break::vanishes(b, a))
{
out.remove(at);
}
}
out
}
pub fn heading_slug_text(inlines: &[Inline]) -> String {
let mut out = String::new();
push_plain(inlines, true, &mut out, &mut Vec::new());
out
}
fn push_plain(inlines: &[Inline], for_slug: bool, out: &mut String, soft_breaks: &mut Vec<usize>) {
for inline in inlines {
match inline {
Inline::Text(t) => out.push_str(t),
Inline::Html(h) if !for_slug => out.push_str(h),
Inline::Html(_) => {}
Inline::Bold(inner)
| Inline::Italic(inner)
| Inline::Strikethrough(inner)
| Inline::Highlight(inner) => push_plain(inner, for_slug, out, soft_breaks),
Inline::Code(c) => out.push_str(c),
Inline::Link { text, .. } => push_plain(text, for_slug, out, soft_breaks),
Inline::Image { alt, .. } if !for_slug => out.push_str(alt),
Inline::Image { .. } => {}
Inline::HtmlComment(_) => {}
Inline::FootnoteReference { .. } => {}
Inline::Math { source, display } => {
let delim = if *display { "$$" } else { "$" };
out.push_str(delim);
out.push_str(source);
out.push_str(delim);
}
Inline::SoftBreak if for_slug => out.push('\n'),
Inline::SoftBreak => {
soft_breaks.push(out.len());
out.push(' ');
}
Inline::HardBreak => out.push('\n'),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn heading_slug_text_drops_alt_text_and_markup() {
let inlines = [
Inline::Text("a ".into()),
Inline::Image {
alt: "alt".into(),
url: "x.png".into(),
},
Inline::Html("<b>".into()),
Inline::Bold(vec![Inline::Text("b".into())]),
Inline::Html("</b>".into()),
Inline::SoftBreak,
Inline::Text("c".into()),
];
assert_eq!(inlines_to_plain(&inlines), "a alt<b>b</b> c");
assert_eq!(heading_slug_text(&inlines), "a b\nc");
}
#[test]
fn plain_text_drops_a_soft_break_between_cjk_glyphs() {
let inlines = [
Inline::Text("日本".into()),
Inline::SoftBreak,
Inline::Italic(vec![Inline::Text("語".into())]),
Inline::SoftBreak,
Inline::Text("abc".into()),
];
assert_eq!(inlines_to_plain(&inlines), "日本語 abc");
assert_eq!(heading_slug_text(&inlines), "日本\n語\nabc");
let two = [
Inline::Text("日".into()),
Inline::SoftBreak,
Inline::HtmlComment("<!-- c -->".into()),
Inline::SoftBreak,
Inline::Text("本".into()),
];
assert_eq!(
inlines_to_plain(&two),
"日 本",
"a break beside a break stays"
);
}
fn round_trip(cols: &[Option<u32>]) -> SrcLines {
let src = SrcLines::new(3, cols);
assert_eq!(src.len(), cols.len());
assert_eq!(src.cols().collect::<Vec<_>>(), cols);
assert_eq!(src.col(cols.len()), None, "past the end");
assert_eq!(src.span(), 3..3 + cols.len() as u32);
src
}
#[test]
fn common_shapes_store_uniformly() {
for cols in [
&[][..],
&[Some(2)],
&[Some(0), Some(0), Some(0)],
&[Some(0), None], &[None, Some(4), Some(4), None], &[Some(2), None, Some(2), Some(2)], &[None], &[None, None], ] {
let src = round_trip(cols);
assert!(matches!(src.cols, Cols::Uniform { .. }), "{cols:?}");
}
}
#[test]
fn other_shapes_store_every_line() {
for cols in [
&[Some(2), Some(1)][..],
&[None, None, None],
&[Some(0), None, None, Some(0), None],
&[Some(2), Some(70_000)],
] {
let src = round_trip(cols);
assert!(matches!(src.cols, Cols::Varied(_)), "{cols:?}");
}
}
#[test]
fn equal_columns_build_equal_values() {
let a = SrcLines::new(0, &[None, Some(1), None]);
let b = SrcLines::new(0, &a.cols().collect::<Vec<_>>());
assert_eq!(a, b);
assert_ne!(a, SrcLines::new(0, &[None, Some(2), None]));
assert_ne!(a, SrcLines::new(0, &[Some(1), None, None]));
assert_eq!(SrcLines::new(0, &[]), SrcLines::default());
}
#[test]
fn debug_shows_the_per_line_columns() {
assert_eq!(
format!("{:?}", SrcLines::new(1, &[None, Some(2)])),
"SrcLines { first: 1, content_col: [None, Some(2)] }"
);
}
}