pub(crate) use crate::classify_kernel::{
CharIndex,
ClassifyCtxKernel as ClassifyCtx,
KernelClassification,
LineClass,
classify_seq,
};
#[derive(Debug, Eq, PartialEq)]
pub(crate) struct ClassifiedLine<'line> {
pub(crate) class: LineClass,
pub(crate) body: &'line str,
}
#[derive(Default)]
pub(crate) struct ListContinuationState {
active: Option<(usize, usize)>,
has_blank_line: bool,
}
pub(crate) fn quote_depth(prefix: &str) -> usize {
prefix.bytes().filter(|byte| *byte == b'>').count()
}
impl ListContinuationState {
pub(crate) fn reset(&mut self) {
self.active = None;
self.has_blank_line = false;
}
pub(crate) fn observe(&mut self, line: &str, classified: &ClassifiedLine<'_>) -> Option<usize> {
let prefix = &line[..line.len() - classified.body.len()];
let quote_depth = quote_depth(prefix);
let indent = structural_content_indent(line, classified.body);
if classified.class == LineClass::Blank {
self.has_blank_line = self.active.is_some();
return None;
}
if classified.class == LineClass::ListItem {
let required = list_content_indent(classified.body, indent);
self.active = Some((quote_depth, required));
self.has_blank_line = false;
return Some(required);
}
if let Some(required) = self.continuation_indent(classified, quote_depth, indent) {
self.has_blank_line = false;
Some(required)
} else {
self.reset();
None
}
}
fn continuation_indent(
&self,
classified: &ClassifiedLine<'_>,
quote_depth: usize,
indent: usize,
) -> Option<usize> {
let (depth, required) = self.active?;
if depth != quote_depth {
return None;
}
match classified.class {
LineClass::ParagraphText if !self.has_blank_line => Some(required),
LineClass::ParagraphText | LineClass::Literal if indent >= required => Some(required),
_ => None,
}
}
}
pub(crate) fn list_content_indent(body: &str, indent: usize) -> usize {
let marker = body.trim_start_matches([' ', '\t']);
let marker_len = marker
.chars()
.take_while(|ch| !matches!(ch, ' ' | '\t'))
.count();
let marker_end = indent + marker_len;
let mut content_column = marker_end;
for ch in marker.chars().skip(marker_len) {
match ch {
' ' => content_column += 1,
'\t' => content_column += 4 - content_column % 4,
_ => break,
}
}
let separator_width = content_column - marker_end;
let separator_width = if (1..=4).contains(&separator_width) {
separator_width
} else {
1
};
marker_end + separator_width
}
pub(crate) fn structural_content_indent(line: &str, body: &str) -> usize {
let prefix = &line[..line.len() - body.len()];
let source = if prefix.contains('>') { body } else { line };
let mut columns = 0;
for ch in source.chars() {
match ch {
' ' => columns += 1,
'\t' => columns += 4 - columns % 4,
_ => break,
}
}
columns
}
#[must_use]
pub fn classify_line(line: &str, ctx: &ClassifyCtx) -> LineClass {
classify_line_with_body(line, ctx).class
}
#[must_use]
pub(crate) fn is_setext_text_line(line: &str, ctx: &ClassifyCtx) -> bool {
let chars = line.chars().collect::<Vec<_>>();
crate::classify_kernel::consumers::is_setext_text_seq(&chars, ctx)
}
#[must_use]
pub(crate) fn is_setext_underline_line(line: &str, ctx: &ClassifyCtx) -> bool {
let chars = line.chars().collect::<Vec<_>>();
crate::classify_kernel::consumers::is_setext_underline_seq(&chars, ctx)
}
#[must_use]
pub(crate) fn is_canonical_break_line(line: &str, ctx: &ClassifyCtx) -> bool {
let chars = line.chars().collect::<Vec<_>>();
crate::classify_kernel::consumers::is_canonical_break_seq(&chars, ctx)
}
#[must_use]
pub(crate) fn is_atx_heading_line(line: &str, ctx: &ClassifyCtx) -> bool {
let chars = line.chars().collect::<Vec<_>>();
crate::classify_kernel::consumers::is_atx_heading_seq(&chars, ctx)
}
#[must_use]
pub(crate) fn classify_line_with_body<'line>(
line: &'line str,
ctx: &ClassifyCtx,
) -> ClassifiedLine<'line> {
let chars = line.chars().collect::<Vec<_>>();
let KernelClassification { class, body_start } = classify_seq(&chars, ctx);
let body_start = byte_offset_at_char_index(line, body_start);
ClassifiedLine {
class,
body: &line[body_start..],
}
}
fn byte_offset_at_char_index(line: &str, CharIndex(target): CharIndex) -> usize {
debug_assert!(target <= line.chars().count());
line.char_indices()
.nth(target)
.map_or(line.len(), |(byte_offset, _)| byte_offset)
}
#[cfg(test)]
mod tests {
use rstest::rstest;
use super::*;
#[test]
fn maps_the_kernel_offset_on_a_unicode_prefix_boundary() {
let classified = classify_line_with_body("> élan", &ClassifyCtx::default());
assert_eq!(classified.class, LineClass::ParagraphText);
assert_eq!(classified.body, "élan");
}
fn observe(state: &mut ListContinuationState, line: &str) -> Option<usize> {
let classified = classify_line_with_body(line, &ClassifyCtx::default());
state.observe(line, &classified)
}
#[test]
fn list_item_sets_the_content_column() {
let mut state = ListContinuationState::default();
assert_eq!(observe(&mut state, "- item"), Some(2));
assert_eq!(state.active, Some((0, 2)));
}
#[test]
fn lazy_paragraph_continues_before_a_blank_line() {
let mut state = ListContinuationState::default();
observe(&mut state, "- item");
assert_eq!(observe(&mut state, "continuation"), Some(2));
}
#[test]
fn paragraph_after_blank_line_requires_the_content_column() {
let mut state = ListContinuationState::default();
observe(&mut state, "- item");
assert_eq!(observe(&mut state, ""), None);
assert_eq!(observe(&mut state, "continuation"), None);
assert!(state.active.is_none(), "the outdented line left the list");
}
#[test]
fn indented_paragraph_continues_after_blank_line() {
let mut state = ListContinuationState::default();
observe(&mut state, "- item");
observe(&mut state, "");
assert_eq!(observe(&mut state, " continuation"), Some(2));
assert!(!state.has_blank_line, "continuing clears the blank marker");
}
#[rstest]
#[case("- item", "> continuation")]
#[case("> - item", "continuation")]
fn quote_depth_change_ends_the_item(#[case] item: &str, #[case] next: &str) {
let mut state = ListContinuationState::default();
assert_eq!(observe(&mut state, item), Some(2));
assert_eq!(observe(&mut state, next), None);
assert!(
state.active.is_none(),
"a changed quote depth must reset the item"
);
}
#[test]
fn new_item_after_blank_line_restarts_the_list() {
let mut state = ListContinuationState::default();
observe(&mut state, "- item");
observe(&mut state, "");
assert_eq!(observe(&mut state, "- second"), Some(2));
assert_eq!(state.active, Some((0, 2)));
assert!(!state.has_blank_line, "starting an item clears the marker");
}
#[test]
fn literal_content_at_the_content_column_continues_the_item() {
let mut state = ListContinuationState::default();
observe(&mut state, "- item");
assert_eq!(observe(&mut state, " code"), Some(2));
}
#[test]
fn literal_content_below_the_content_column_ends_the_item() {
let mut state = ListContinuationState::default();
assert_eq!(observe(&mut state, " - deep"), Some(5));
assert_eq!(observe(&mut state, " code"), None);
assert!(state.active.is_none(), "shallow code left the item");
}
#[test]
fn blank_line_without_an_item_leaves_the_marker_clear() {
let mut state = ListContinuationState::default();
assert_eq!(observe(&mut state, ""), None);
assert!(!state.has_blank_line);
}
#[test]
fn reset_forgets_the_item_and_blank_marker() {
let mut state = ListContinuationState::default();
observe(&mut state, "- item");
observe(&mut state, "");
state.reset();
assert_eq!(state.active, None);
assert!(!state.has_blank_line);
}
}