texform-transform 0.1.0

Profile-based AST transform engine for TeXForm (internal; use the texform crate)
Documentation
use texform_core::ast::Ast;
use texform_core::parse::{ParseConfig, ParseContext};
use texform_interface::syntax_node::{ContentMode, SyntaxNode};
use texform_knowledge::builtin::base;
use texform_transform::{
    BuildConfig, NormalizationLevel, Profile, RewriteError, RewriteReport, RuleFidelity,
    RuleTarget, RuleTargetKey, TransformContext, TransformError, collect_eliminated_violations,
};

fn parse_to_ast(parse_ctx: &ParseContext, src: &str) -> Ast {
    let document = parse_ctx
        .parse(src, &ParseConfig::default())
        .try_into_document()
        .expect("source should parse")
        .0;
    Ast::from_syntax_root(&document.to_syntax())
}

#[test]
fn transform_contract_collector_reports_all_eliminated_form_violations() {
    let parse_ctx = ParseContext::from_packages(&["base"]);
    let ast = parse_to_ast(&parse_ctx, r"\break x \goodbreak y");
    let eliminated_forms = [
        RuleTarget::Command(&base::cmd::BREAK).key(),
        RuleTarget::Command(&base::cmd::GOODBREAK).key(),
    ];

    let violations = collect_eliminated_violations(&ast, &parse_ctx, &eliminated_forms);

    assert_eq!(violations.len(), 2);
    assert_eq!(
        violation_target_names(&violations),
        vec!["break", "goodbreak"]
    );
    assert_eq!(violations[0].node_name.as_deref(), Some("break"));
    assert_eq!(violations[1].node_name.as_deref(), Some("goodbreak"));
    assert!(violations[0].to_string().contains("command `break`"));
}

#[test]
fn transform_contract_engine_reports_violation_after_full_pipeline() {
    let parse_ctx = ParseContext::from_packages(&["base"]);
    let mut ast = parse_to_ast(&parse_ctx, r"A \buildrel f \over = B");
    let context =
        TransformContext::from_build_config(BuildConfig::profile(Profile::Authoring), &parse_ctx)
            .expect("transform context should build");

    let err = context
        .run(&mut ast, &parse_ctx)
        .expect_err("full transform should fail on an uneliminated over infix");

    assert_contract_error(
        err,
        RuleTarget::Command(&base::cmd::OVER).key(),
        Some("over"),
    );
}

#[test]
fn transform_contract_rewrite_phase_does_not_run_eliminated_form_check() {
    let parse_ctx = ParseContext::from_packages(&["base"]);
    let mut ast = parse_to_ast(&parse_ctx, r"A \buildrel f \over = B");
    let context =
        TransformContext::from_build_config(BuildConfig::profile(Profile::Authoring), &parse_ctx)
            .expect("transform context should build");
    let mut report = RewriteReport::default();

    texform_transform::rewrite::run(
        &mut ast,
        &parse_ctx,
        context.rewrite_plan(),
        100,
        &mut report,
    )
    .expect("rewrite alone should not fail the eliminated-form contract");

    let violations =
        collect_eliminated_violations(&ast, &parse_ctx, context.rewrite_plan().eliminated_forms());
    assert_eq!(violation_target_names(&violations), vec!["over"]);
}

#[test]
fn transform_contract_does_not_globally_eliminate_cr_line_breaks() {
    let parse_ctx = ParseContext::from_packages(&["base", "ams"]);
    let context =
        TransformContext::from_build_config(BuildConfig::profile(Profile::Corpus), &parse_ctx)
            .expect("transform context should build");

    let eliminated_names = context
        .rewrite_plan()
        .eliminated_forms()
        .iter()
        .map(|target| target.name)
        .collect::<Vec<_>>();

    assert!(
        !eliminated_names.contains(&"cr"),
        "plain-TeX matrix rules only consume \\cr inside their wrapper command bodies"
    );
}

#[test]
fn builtin_rule_fidelity_meets_level_floor() {
    for rule in texform_transform::rewrite::all_rules() {
        let meta = rule.meta();
        assert!(
            meta.fidelity >= meta.level.min_fidelity(),
            "{} declares {:?} fidelity below {:?} floor ({:?})",
            meta.key,
            meta.fidelity,
            meta.level,
            meta.level.min_fidelity()
        );
    }
}

#[test]
fn fidelity_order_matches_level_floors() {
    assert!(RuleFidelity::Full > RuleFidelity::Approximate);
    assert!(RuleFidelity::Approximate > RuleFidelity::Semantic);
    assert_eq!(
        NormalizationLevel::Standard.min_fidelity(),
        RuleFidelity::Approximate
    );
    assert_eq!(
        NormalizationLevel::Expand.min_fidelity(),
        RuleFidelity::Approximate
    );
    assert_eq!(
        NormalizationLevel::Drop.min_fidelity(),
        RuleFidelity::Semantic
    );
    assert_eq!(
        NormalizationLevel::Equiv.min_fidelity(),
        RuleFidelity::Semantic
    );
}

#[test]
fn transform_contract_accepts_prime_after_standard_rewrite() {
    let parse_ctx = ParseContext::from_packages(&["base"]);
    let mut ast = parse_to_ast(&parse_ctx, r"\prime");
    let context =
        TransformContext::from_build_config(BuildConfig::profile(Profile::Authoring), &parse_ctx)
            .expect("transform context should build");

    context
        .run(&mut ast, &parse_ctx)
        .expect("prime should be eliminated before the contract check");

    assert_eq!(
        ast.to_syntax_root(),
        SyntaxNode::Root {
            mode: ContentMode::Math,
            children: vec![SyntaxNode::Prime { count: 1 }],
        }
    );
}

fn violation_target_names(
    violations: &[texform_transform::ContractViolation],
) -> Vec<&'static str> {
    violations
        .iter()
        .map(|violation| violation.target.name)
        .collect()
}

fn assert_contract_error(
    err: TransformError,
    expected_target: RuleTargetKey,
    expected_node_name: Option<&str>,
) {
    let TransformError::Rewrite(RewriteError::ContractViolation { target, node_name }) = err else {
        panic!("expected rewrite contract violation, got {err:?}");
    };

    assert_eq!(target, expected_target);
    assert_eq!(node_name.as_deref(), expected_node_name);
}