mdstream 0.3.0

Streaming-first Markdown middleware for LLM output (committed + pending blocks, render-agnostic).
Documentation
use super::footnotes::{is_footnote_continuation, is_footnote_definition_start};
use super::mode::BlockMode;
use crate::syntax::facts::{
    fence_start, is_atx_heading_start as is_heading, is_blank_line as is_empty_line,
    is_blockquote_start, is_list_continuation, is_list_item_start, is_list_item_start_prefix,
    is_thematic_break, setext_underline_char,
};

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum BoundaryDecision {
    SameBlock,
    StartNewBlock,
}

impl BoundaryDecision {
    pub(super) fn starts_new_block(self) -> bool {
        matches!(self, Self::StartNewBlock)
    }
}

#[derive(Debug, Clone)]
pub(super) struct BoundaryDetector<'a> {
    current_mode: &'a BlockMode,
    block_start_mode: BlockMode,
    current_block_start_line: usize,
    curr_has_newline: bool,
    custom_boundary_starts: bool,
}

impl<'a> BoundaryDetector<'a> {
    pub(super) fn new(
        current_mode: &'a BlockMode,
        block_start_mode: BlockMode,
        current_block_start_line: usize,
        curr_has_newline: bool,
        custom_boundary_starts: bool,
    ) -> Self {
        Self {
            current_mode,
            block_start_mode,
            current_block_start_line,
            curr_has_newline,
            custom_boundary_starts,
        }
    }

    pub(super) fn detect(
        &self,
        prev: &str,
        curr: &str,
        curr_line_index: usize,
    ) -> BoundaryDecision {
        if let Some(decision) = self.footnote_definition_decision(curr) {
            return decision;
        }
        if self.must_stay_in_current_block() {
            return BoundaryDecision::SameBlock;
        }

        if is_empty_line(prev) && !is_empty_line(curr) {
            return self.after_blank_line_decision(curr);
        }

        if matches!(self.current_mode, BlockMode::Paragraph | BlockMode::Unknown)
            && setext_underline_char(curr).is_some()
            && !is_empty_line(prev)
            && self.current_block_start_line + 1 == curr_line_index
        {
            return BoundaryDecision::SameBlock;
        }

        if self.current_line_can_interrupt(prev, curr, curr_line_index) {
            return BoundaryDecision::StartNewBlock;
        }

        BoundaryDecision::SameBlock
    }

    fn must_stay_in_current_block(&self) -> bool {
        match self.current_mode {
            BlockMode::CodeFence { .. } | BlockMode::CustomBoundary { .. } => true,
            BlockMode::MathBlock { open_count } => open_count % 2 == 1,
            BlockMode::HtmlBlock { stack, in_comment } => *in_comment || !stack.is_empty(),
            _ => false,
        }
    }

    fn footnote_definition_decision(&self, curr: &str) -> Option<BoundaryDecision> {
        if !matches!(self.current_mode, BlockMode::FootnoteDefinition) {
            return None;
        }
        if is_empty_line(curr) || is_footnote_continuation(curr) {
            return Some(BoundaryDecision::SameBlock);
        }
        Some(BoundaryDecision::StartNewBlock)
    }

    fn after_blank_line_decision(&self, curr: &str) -> BoundaryDecision {
        let in_list = matches!(self.current_mode, BlockMode::List)
            || matches!(self.block_start_mode, BlockMode::List);
        let in_blockquote = matches!(self.current_mode, BlockMode::BlockQuote)
            || matches!(self.block_start_mode, BlockMode::BlockQuote);

        if in_list && (is_list_continuation(curr) || is_list_item_start_prefix(curr)) {
            return BoundaryDecision::SameBlock;
        }
        if in_blockquote && is_blockquote_start(curr) {
            return BoundaryDecision::SameBlock;
        }

        BoundaryDecision::StartNewBlock
    }

    fn current_line_can_interrupt(&self, prev: &str, curr: &str, curr_line_index: usize) -> bool {
        if is_heading(curr) || (self.curr_has_newline && is_thematic_break(curr)) {
            return true;
        }
        if fence_start(curr).is_some() {
            return true;
        }
        if self.custom_boundary_starts {
            return true;
        }
        if is_footnote_definition_start(curr) {
            return true;
        }
        if is_blockquote_start(curr)
            && !is_blockquote_start(prev)
            && !matches!(self.current_mode, BlockMode::BlockQuote)
        {
            return true;
        }
        if is_list_item_start(curr)
            && !is_list_item_start(prev)
            && !matches!(self.current_mode, BlockMode::List)
        {
            return true;
        }

        matches!(self.current_mode, BlockMode::Paragraph | BlockMode::Unknown)
            && self.curr_has_newline
            && is_table_delimiter(curr)
            && prev.contains('|')
            && curr_line_index >= 1
            && self.current_block_start_line < curr_line_index - 1
    }
}

pub(super) fn is_table_delimiter(line: &str) -> bool {
    let s = line.trim();
    if s.is_empty() {
        return false;
    }
    let mut has_dash = false;
    for c in s.chars() {
        match c {
            '|' | ':' | ' ' | '\t' => {}
            '-' => has_dash = true,
            _ => return false,
        }
    }
    has_dash
}