mdforge 0.1.0

Define, validate, and render typed Markdown extensions for LLM-generated content.
Documentation
use crate::{BlockNode, Diagnostic, Document, ErrorCode, Level, MdEvent, Node, Span};

use super::{args::parse_args, Forge};

pub(super) fn parse_document(forge: &Forge, input: &str) -> Result<Document, Vec<Diagnostic>> {
    let lines: Vec<&str> = input.lines().collect();
    let mut diagnostics = Vec::new();
    let (nodes, _, _) = parse_lines(forge, &lines, 0, 0, &mut diagnostics, false);
    if diagnostics.is_empty() {
        Ok(Document { nodes })
    } else {
        Err(diagnostics)
    }
}

fn parse_lines(
    forge: &Forge,
    lines: &[&str],
    mut index: usize,
    mut offset: usize,
    diagnostics: &mut Vec<Diagnostic>,
    stop_on_close: bool,
) -> (Vec<Node>, usize, bool) {
    let _ = forge;
    let mut nodes = Vec::new();
    let mut markdown = String::new();

    while index < lines.len() {
        let line = lines[index];
        let line_len_with_nl = line.len() + 1;

        if line.trim() == ":::" {
            if stop_on_close {
                if !markdown.is_empty() {
                    nodes.push(Node::Markdown(vec![MdEvent::Text(markdown)]));
                }
                return (nodes, offset + line_len_with_nl, true);
            }
            markdown.push_str(line);
            markdown.push('\n');
            offset += line_len_with_nl;
            index += 1;
            continue;
        }

        if let Some(rest) = line.strip_prefix(":::") {
            if !markdown.is_empty() {
                nodes.push(Node::Markdown(vec![MdEvent::Text(markdown)]));
                markdown = String::new();
            }

            let open_span = Span {
                start: offset,
                end: offset + line.len(),
            };
            let parts = rest.split_whitespace().collect::<Vec<_>>();
            if parts.is_empty() {
                diagnostics.push(Diagnostic {
                    level: Level::Error,
                    code: ErrorCode::UnknownBlock,
                    message: "block name is missing".to_string(),
                    span: open_span,
                    suggestion: None,
                });
                offset += line_len_with_nl;
                index += 1;
                continue;
            }

            let name = parts[0].to_string();
            let args = parse_args(&parts[1..], open_span.start, diagnostics);

            index += 1;
            offset += line_len_with_nl;
            let (body, new_offset, closed) =
                parse_lines(forge, lines, index, offset, diagnostics, true);
            if !closed {
                diagnostics.push(Diagnostic {
                    level: Level::Error,
                    code: ErrorCode::BlockNotClosed,
                    message: format!("block '{}' is not closed", name),
                    span: open_span.clone(),
                    suggestion: Some("add a closing ::: line".to_string()),
                });
                nodes.push(Node::Block(BlockNode {
                    name,
                    args,
                    body,
                    span: open_span,
                }));
                return (nodes, offset, false);
            }

            nodes.push(Node::Block(BlockNode {
                name,
                args,
                body,
                span: open_span,
            }));

            let consumed_bytes = new_offset - offset;
            let consumed_lines = lines[index..]
                .iter()
                .scan(0usize, |sum, l| {
                    *sum += l.len() + 1;
                    Some(*sum)
                })
                .position(|s| s == consumed_bytes)
                .map(|p| p + 1)
                .unwrap_or(lines.len() - index);
            index += consumed_lines;
            offset = new_offset;
            continue;
        }

        markdown.push_str(line);
        markdown.push('\n');
        offset += line_len_with_nl;
        index += 1;
    }

    if !markdown.is_empty() {
        nodes.push(Node::Markdown(vec![MdEvent::Text(markdown)]));
    }

    (nodes, offset, false)
}