sim-codec-doc 0.1.1

Markup document codec backends for SIM.
Documentation
use std::collections::BTreeSet;

use sim_codec_doc::{
    AsciiDocBackend, BackendStatus, LatexBackend, MarkdownBackend, MarkupBackend, MarkupBlock,
    MarkupDecodeOptions, MarkupDoc, MarkupEncodeOptions, TypstBackend, backend_catalog,
};

struct Fixture {
    id: &'static str,
    backend: Box<dyn MarkupBackend>,
    simple: &'static str,
    math_table: &'static str,
    raw_preserve: &'static str,
}

fn fixtures() -> Vec<Fixture> {
    vec![
        Fixture {
            id: "asciidoc",
            backend: Box::new(AsciiDocBackend),
            simple: include_str!("fixtures/simple.adoc"),
            math_table: include_str!("fixtures/math-table.adoc"),
            raw_preserve: include_str!("fixtures/raw-preserve.adoc"),
        },
        Fixture {
            id: "latex",
            backend: Box::new(LatexBackend),
            simple: include_str!("fixtures/simple.tex"),
            math_table: include_str!("fixtures/math-table.tex"),
            raw_preserve: include_str!("fixtures/raw-preserve.tex"),
        },
        Fixture {
            id: "markdown",
            backend: Box::new(MarkdownBackend),
            simple: include_str!("fixtures/simple.md"),
            math_table: include_str!("fixtures/math-table.md"),
            raw_preserve: include_str!("fixtures/raw-preserve.md"),
        },
        Fixture {
            id: "typst",
            backend: Box::new(TypstBackend),
            simple: include_str!("fixtures/simple.typ"),
            math_table: include_str!("fixtures/math-table.typ"),
            raw_preserve: include_str!("fixtures/raw-preserve.typ"),
        },
    ]
}

#[test]
fn all_implemented_backends_roundtrip_simple_fixture() {
    assert_fixture_coverage();
    for fixture in fixtures() {
        assert_semantic_roundtrip(fixture.id, fixture.backend.as_ref(), fixture.simple);
    }
}

#[test]
fn all_implemented_backends_roundtrip_math_table_fixture() {
    assert_fixture_coverage();
    for fixture in fixtures() {
        assert_semantic_roundtrip(fixture.id, fixture.backend.as_ref(), fixture.math_table);
    }
}

#[test]
fn raw_preserve_fixture_names_every_loss() {
    assert_fixture_coverage();
    for fixture in fixtures() {
        let preserve = MarkupDecodeOptions {
            preserve_source: false,
            preserve_raw: true,
        };
        let (_doc, fidelity) = fixture
            .backend
            .decode(fixture.raw_preserve, &preserve)
            .unwrap_or_else(|err| panic!("{} raw preserve fixture failed: {err}", fixture.id));
        assert!(
            fidelity.dropped.is_empty(),
            "{} should preserve raw fragments when requested: {:?}",
            fixture.id,
            fidelity.dropped
        );
        assert!(
            !fidelity.preserved_raw.is_empty(),
            "{} should report preserved raw fragments",
            fixture.id
        );

        let report = MarkupDecodeOptions {
            preserve_source: false,
            preserve_raw: false,
        };
        let (_doc, fidelity) = fixture
            .backend
            .decode(fixture.raw_preserve, &report)
            .unwrap_or_else(|err| panic!("{} raw report fixture failed: {err}", fixture.id));
        assert!(
            !fidelity.dropped.is_empty(),
            "{} should report raw-fragment losses",
            fixture.id
        );
        for loss in &fidelity.dropped {
            assert!(
                !loss.path.trim().is_empty(),
                "{} reported an unnamed raw loss",
                fixture.id
            );
            assert!(
                !loss.reason.trim().is_empty(),
                "{} reported a raw loss without a reason",
                fixture.id
            );
        }
    }
}

fn assert_semantic_roundtrip(id: &str, backend: &dyn MarkupBackend, source: &str) {
    let decode_opts = MarkupDecodeOptions {
        preserve_source: false,
        preserve_raw: true,
    };
    let (doc, decode_fidelity) = backend
        .decode(source, &decode_opts)
        .unwrap_or_else(|err| panic!("{id} fixture decode failed: {err}"));
    assert!(
        decode_fidelity.dropped.is_empty(),
        "{id} fixture decode dropped semantic content: {:?}",
        decode_fidelity.dropped
    );

    let encode_opts = MarkupEncodeOptions {
        fail_on_loss: true,
        preserve_raw: true,
    };
    let (encoded, encode_fidelity) = backend
        .encode(&doc, &encode_opts)
        .unwrap_or_else(|err| panic!("{id} fixture encode failed: {err}"));
    assert!(
        encode_fidelity.dropped.is_empty(),
        "{id} fixture encode dropped semantic content: {:?}",
        encode_fidelity.dropped
    );

    let (decoded, second_fidelity) = backend
        .decode(&encoded, &decode_opts)
        .unwrap_or_else(|err| panic!("{id} encoded fixture decode failed: {err}"));
    assert!(
        second_fidelity.dropped.is_empty(),
        "{id} encoded fixture decode dropped semantic content: {:?}",
        second_fidelity.dropped
    );
    assert_eq!(semantic_doc(decoded), semantic_doc(doc), "{id} round-trip");
}

fn assert_fixture_coverage() {
    let implemented: BTreeSet<String> = backend_catalog()
        .into_iter()
        .filter(|info| info.status == BackendStatus::Implemented)
        .map(|info| info.id.to_string())
        .collect();
    let fixture_ids: BTreeSet<String> = fixtures()
        .into_iter()
        .map(|fixture| fixture.id.to_owned())
        .collect();
    assert_eq!(fixture_ids, implemented);
}

fn semantic_doc(mut doc: MarkupDoc) -> MarkupDoc {
    doc.source = None;
    doc.blocks = doc.blocks.into_iter().map(block_without_span).collect();
    doc
}

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.into_iter().map(block_without_span).collect(),
            span: None,
        },
        MarkupBlock::List { ordered, items, .. } => MarkupBlock::List {
            ordered,
            items: items
                .into_iter()
                .map(|item| item.into_iter().map(block_without_span).collect())
                .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,
        },
    }
}