sim-codec-doc 0.1.1

Markup document codec backends for SIM.
Documentation
use crate::{
    Inline, LatexBackend, MarkupBackend, MarkupBlock, MarkupDecodeOptions, MarkupEncodeOptions,
};

#[test]
fn article_subset_roundtrips() {
    let source = concat!(
        "\\title{Guide}\n\n",
        "Intro with \\emph{small}, \\textbf{strong}, \\texttt{code}, ",
        "\\href{https://example.test}{link}, and $x^2$.\n\n",
        "\\section{Data}\n\n",
        "\\begin{itemize}\n",
        "\\item first\n",
        "\\item second\n",
        "\\end{itemize}\n\n",
        "\\begin{tabular}{ll}\n",
        "Name & Value \\\\\n",
        "SIM & Runtime\n",
        "\\end{tabular}\n\n",
        "\\begin{verbatim}\n",
        "let x = 1;\n",
        "\\end{verbatim}\n\n",
        "\\[\n",
        "E = mc^2\n",
        "\\]\n\n",
        "\\begin{figure}\n",
        "\\includegraphics{plot.pdf}\n",
        "\\caption{Plot}\n",
        "\\end{figure}\n"
    );
    let backend = LatexBackend;
    let opts = MarkupDecodeOptions {
        preserve_source: false,
        preserve_raw: true,
    };
    let (doc, fidelity) = backend.decode(source, &opts).unwrap();
    assert!(fidelity.dropped.is_empty());
    assert_eq!(doc.title.as_deref(), Some("Guide"));
    assert!(has_level_one_heading(&doc.blocks));
    assert_table_shape(&doc.blocks);
    assert!(
        doc.blocks
            .iter()
            .any(|block| matches!(block, MarkupBlock::Figure { src, .. } if src == "plot.pdf"))
    );
    assert!(doc.blocks.iter().any(
        |block| matches!(block, MarkupBlock::MathBlock { source, .. } if source.text == "\nE = mc^2\n")
    ));

    let (encoded, encode_fidelity) = backend
        .encode(&doc, &MarkupEncodeOptions::default())
        .unwrap();
    assert!(encode_fidelity.dropped.is_empty());
    let (decoded, _) = backend.decode(&encoded, &opts).unwrap();
    assert_eq!(
        blocks_without_spans(&decoded.blocks),
        blocks_without_spans(&doc.blocks)
    );
}

#[test]
fn input_is_preserved_not_resolved() {
    let backend = LatexBackend;
    let source = "Intro.\n\n\\input{private}\n\nOutro.\n";
    let preserve = MarkupDecodeOptions {
        preserve_source: false,
        preserve_raw: true,
    };
    let (doc, fidelity) = backend.decode(source, &preserve).unwrap();
    assert!(fidelity.dropped.is_empty());
    assert!(
        fidelity
            .preserved_raw
            .iter()
            .any(|raw| raw.contains("\\input{private}"))
    );
    assert!(
        fidelity
            .warnings
            .iter()
            .any(|warning| warning.contains("not resolved"))
    );
    assert!(doc.blocks.iter().any(
        |block| matches!(block, MarkupBlock::Raw { text, .. } if text.contains("\\input{private}"))
    ));

    let report = MarkupDecodeOptions {
        preserve_source: false,
        preserve_raw: false,
    };
    let (doc, fidelity) = backend.decode(source, &report).unwrap();
    assert!(fidelity.preserved_raw.is_empty());
    assert!(fidelity.dropped.iter().any(|loss| loss.path == "input"));
    assert!(
        doc.blocks
            .iter()
            .all(|block| !matches!(block, MarkupBlock::Raw { .. }))
    );
}

#[test]
fn math_source_preserved_byte_for_byte() {
    let backend = LatexBackend;
    let source = "Inline $\\alpha + \\beta$.\n\n\\[\\int_0^1 x\\,dx\\]\n";
    let (doc, fidelity) = backend
        .decode(
            source,
            &MarkupDecodeOptions {
                preserve_source: false,
                preserve_raw: true,
            },
        )
        .unwrap();
    assert!(fidelity.dropped.is_empty());
    assert!(doc.blocks.iter().any(|block| {
        matches!(
            block,
            MarkupBlock::Paragraph { content, .. }
                if content.iter().any(|inline| matches!(
                    inline,
                    Inline::Math(source) if source.text == "\\alpha + \\beta"
                ))
        )
    }));
    assert!(doc.blocks.iter().any(
        |block| matches!(block, MarkupBlock::MathBlock { source, .. } if source.text == "\\int_0^1 x\\,dx")
    ));
}

fn has_level_one_heading(blocks: &[MarkupBlock]) -> bool {
    blocks
        .iter()
        .any(|block| matches!(block, MarkupBlock::Heading { level: 1, .. }))
}

fn assert_table_shape(blocks: &[MarkupBlock]) {
    let table = blocks
        .iter()
        .find_map(|block| match block {
            MarkupBlock::Table { header, rows, .. } => Some((header, rows)),
            _ => None,
        })
        .expect("table");
    assert_eq!(table.0.len(), 2);
    assert_eq!(table.1.len(), 1);
}

fn blocks_without_spans(blocks: &[MarkupBlock]) -> Vec<MarkupBlock> {
    blocks.iter().cloned().map(block_without_span).collect()
}

fn block_without_span(block: MarkupBlock) -> MarkupBlock {
    match block {
        MarkupBlock::Heading {
            level, text, id, ..
        } => MarkupBlock::Heading {
            level,
            text,
            id,
            span: None,
        },
        MarkupBlock::Paragraph { content, .. } => MarkupBlock::Paragraph {
            content,
            span: None,
        },
        MarkupBlock::CodeBlock { lang, code, .. } => MarkupBlock::CodeBlock {
            lang,
            code,
            span: None,
        },
        MarkupBlock::MathBlock { source, .. } => MarkupBlock::MathBlock { source, span: None },
        MarkupBlock::Quote { blocks, .. } => MarkupBlock::Quote {
            blocks: blocks_without_spans(&blocks),
            span: None,
        },
        MarkupBlock::List { ordered, items, .. } => MarkupBlock::List {
            ordered,
            items: items
                .into_iter()
                .map(|item| blocks_without_spans(&item))
                .collect(),
            span: None,
        },
        MarkupBlock::Table { header, rows, .. } => MarkupBlock::Table {
            header,
            rows,
            span: None,
        },
        MarkupBlock::Figure { src, caption, .. } => MarkupBlock::Figure {
            src,
            caption,
            span: None,
        },
        MarkupBlock::Raw { backend, text, .. } => MarkupBlock::Raw {
            backend,
            text,
            span: None,
        },
    }
}