texform 0.1.0

LaTeX formula parser, editor, and normalizer — the public TeXForm facade
Documentation
use texform::{
    ContentMode, FlattenGroupsConfig, NormalizeConfig, ParseConfig, Parser, Profile,
    TransformConfig, TransformEngine, bindings::transform_report_to_dto,
};
use texform_transform::FinalizeAstConfig;

#[test]
fn engine_normalize_uses_build_time_profile_and_packages() {
    let engine = TransformEngine::builder()
        .packages(&["base", "physics"])
        .profile(Profile::Authoring)
        .build()
        .expect("engine should build");

    let result = engine
        .normalize(r"\quantity{x}")
        .expect("normalize should succeed");

    assert_eq!(result.normalized, r"\qty { x }");
    assert!(!result.report.rewrite.rules.is_empty());
}

#[test]
fn normalize_with_can_disable_rewrite_without_rebuilding_plan() {
    let engine = TransformEngine::builder()
        .packages(&["base", "physics"])
        .profile(Profile::Equiv)
        .build()
        .expect("engine should build");

    let result = engine
        .normalize_with(
            r"\quantity{x}",
            &NormalizeConfig {
                parse: ParseConfig::STRICT,
                transform: TransformConfig {
                    rewrite_enabled: false,
                    lower_attributes_enabled: false,
                    finalize_ast: FinalizeAstConfig::ENABLED,
                    flatten_groups: FlattenGroupsConfig::DISABLED,
                    max_iterations: 100,
                },
            },
        )
        .expect("normalize should succeed");

    assert_eq!(result.normalized, r"\quantity { x }");
    assert_eq!(result.report.rewrite.iterations, 0);
}

#[test]
fn corpus_normalize_preserves_prime_and_prefix_shorthand_contracts() {
    let engine = TransformEngine::builder()
        .packages(&["base"])
        .profile(Profile::Corpus)
        .build()
        .expect("engine should build");

    let cases = [
        ("U'", "U'"),
        ("H'", "H'"),
        (r"A^{'\alpha}", r"A ^ { ' \alpha }"),
        (r"\vec A_\mu", r"\vec { A } _ { \mu }"),
        (r"\bar C^\mu", r"\bar { C } ^ { \mu }"),
        (r"f^{\prime\prime}", "f''"),
        (r"f^{'}", "f'"),
        (r"f'^2", r"f ^ { ' 2 }"),
        (r"\prime", "'"),
    ];

    for (input, expected) in cases {
        let result = engine
            .normalize(input)
            .unwrap_or_else(|error| panic!("normalize should succeed for {input}: {error:?}"));
        assert_eq!(result.normalized, expected, "input: {input}");
    }
}

#[test]
fn corpus_normalize_keeps_braced_prefix_argument_scope() {
    let engine = TransformEngine::builder()
        .packages(&["base"])
        .profile(Profile::Corpus)
        .build()
        .expect("engine should build");

    let result = engine
        .normalize(r"\vec{A_\mu}")
        .expect("normalize should succeed");

    assert_eq!(result.normalized, r"\vec { A _ { \mu } }");
}

#[test]
fn displaylines_runs_at_corpus_but_not_faithful() {
    let input = r"\displaylines{a \cr b}";

    let faithful = TransformEngine::builder()
        .packages(&["base", "ams"])
        .profile(Profile::Faithful)
        .build()
        .expect("faithful engine should build")
        .normalize(input)
        .expect("faithful normalize should succeed");
    assert!(
        faithful.normalized.contains(r"\displaylines"),
        "faithful output: {}",
        faithful.normalized
    );

    let corpus = TransformEngine::builder()
        .packages(&["base", "ams"])
        .profile(Profile::Corpus)
        .build()
        .expect("corpus engine should build")
        .normalize(input)
        .expect("corpus normalize should succeed");
    assert!(
        corpus.normalized.contains(r"\begin {gather}"),
        "corpus output: {}",
        corpus.normalized
    );
    assert!(
        !corpus.normalized.contains(r"\displaylines"),
        "corpus output: {}",
        corpus.normalized
    );
}

#[test]
fn document_transform_preserves_parse_once_workflow() {
    let engine = TransformEngine::builder()
        .packages(&["base"])
        .profile(Profile::Equiv)
        .build()
        .expect("engine should build");

    let mut document = engine
        .parser()
        .parse("{{x}}")
        .try_into_document()
        .expect("parse should succeed")
        .0;
    let before = document.to_latex().expect("document should serialize");

    let report = engine
        .transform_with(
            &mut document,
            &TransformConfig {
                rewrite_enabled: false,
                lower_attributes_enabled: false,
                finalize_ast: FinalizeAstConfig::ENABLED,
                flatten_groups: FlattenGroupsConfig::STRUCTURAL_ONLY,
                max_iterations: 100,
            },
        )
        .expect("transform should succeed");
    let after = document.to_latex().expect("document should serialize");

    assert_eq!(before, "{ { x } }");
    assert_eq!(after, "x");
    assert_eq!(report.flatten_groups.actions.replaced_single_child, 2);
}

#[test]
fn parser_is_parse_only_and_needs_no_profile() {
    let parser = Parser::builder()
        .packages(&["base"])
        .build()
        .expect("parser should build");

    let output = parser.parse(r"\frac{a}{b}");
    assert!(output.diagnostics().is_empty());
}

#[test]
fn engine_exposes_parser_metadata_queries() {
    let engine = TransformEngine::builder()
        .packages(&["base"])
        .profile(Profile::Authoring)
        .build()
        .expect("engine should build");

    let parser = engine.parser();
    assert!(parser.lookup_command("frac", ContentMode::Math).is_some());
    assert!(
        parser
            .lookup_explicit_command("frac", ContentMode::Math)
            .is_some()
    );
    assert!(parser.lookup_env("array", ContentMode::Math).is_some());
    assert!(parser.lookup_character("le", ContentMode::Math).is_some());
    assert!(parser.is_delimiter_control("lbrace"));
    assert!(parser.knows_command_name("frac"));
    assert!(parser.knows_env_name("array"));
    assert!(parser.knows_character_name("le"));
}

#[test]
fn engine_empty_knowledge_preserves_strict_parse_default() {
    // Empty knowledge must not loosen the TransformEngine parser's strict default.
    let engine = TransformEngine::builder()
        .empty_knowledge()
        .profile(Profile::Equiv)
        .build()
        .expect("engine should build");

    let output = engine.parser().parse(r"\unknowncmd");

    assert!(
        !output.diagnostics().is_empty(),
        "empty_knowledge should not reset TransformEngine parser default to lenient"
    );
}

#[test]
fn engine_empty_knowledge_preserves_explicit_parse_default() {
    // Empty knowledge should only change loaded knowledge, not caller-selected
    // parse defaults.
    let engine = TransformEngine::builder()
        .default_parse_config(ParseConfig::LENIENT)
        .empty_knowledge()
        .profile(Profile::Equiv)
        .build()
        .expect("engine should build");

    let output = engine.parser().parse(r"\unknowncmd");

    assert!(
        output.diagnostics().is_empty(),
        "empty_knowledge should preserve default_parse_config set earlier"
    );
}

#[test]
fn engine_builder_requires_profile() {
    let error = match TransformEngine::builder().packages(&["base"]).build() {
        Ok(_) => panic!("engine profile is required"),
        Err(error) => error,
    };

    assert!(matches!(error, texform::Error::MissingProfile));
}

#[test]
fn engine_builder_disable_rule_without_profile_reports_error() {
    let error = match TransformEngine::builder()
        .disable_rule_by_name("physics/quantity-to-qty")
        .expect("known rule should resolve")
        .build()
    {
        Ok(_) => panic!("engine profile is required"),
        Err(error) => error,
    };

    assert!(matches!(error, texform::Error::MissingProfile));
}

#[test]
fn engine_builder_disables_rule_by_public_name() {
    let engine = TransformEngine::builder()
        .packages(&["base", "physics"])
        .profile(Profile::Authoring)
        .disable_rule_by_name("physics/quantity-to-qty")
        .expect("known rule should resolve")
        .build()
        .expect("engine should build");

    let result = engine
        .normalize(r"\quantity{x}")
        .expect("normalize should succeed");

    assert_eq!(result.normalized, r"\quantity { x }");

    let unknown = TransformEngine::builder()
        .profile(Profile::Authoring)
        .disable_rule_by_name("missing.rule");
    assert!(
        unknown.is_err(),
        "unknown rule names should fail at the facade"
    );
}

#[test]
fn engine_builder_disable_rule_can_precede_profile() {
    let engine = TransformEngine::builder()
        .packages(&["base", "physics"])
        .disable_rule_by_name("physics/quantity-to-qty")
        .expect("known rule should resolve")
        .profile(Profile::Authoring)
        .build()
        .expect("engine should build");

    let result = engine
        .normalize(r"\quantity{x}")
        .expect("normalize should succeed");

    assert_eq!(result.normalized, r"\quantity { x }");
}

#[test]
fn normalize_report_dto_exposes_stable_phase_shape() {
    let engine = TransformEngine::builder()
        .packages(&["base", "physics"])
        .profile(Profile::Authoring)
        .build()
        .expect("engine should build");

    let result = engine
        .normalize(r"\quantity{{\bf x}}")
        .expect("normalize should succeed");
    let dto = transform_report_to_dto(&result.report);

    let quantity_rule = dto
        .rules
        .iter()
        .find(|rule| rule.key == "physics/quantity-to-qty")
        .expect("rewrite rules should expose stable rule entries");
    assert_eq!(quantity_rule.applied_count, 1);
    assert_eq!(quantity_rule.skipped_count, 0);

    assert_eq!(
        dto.flatten_groups.actions.replaced_single_child,
        result.report.flatten_groups.actions.replaced_single_child
    );
    assert_eq!(
        dto.flatten_groups.guards.preserve_empty_group,
        result.report.flatten_groups.guards.preserve_empty_group
    );

    let math_font = dto
        .lower_attributes
        .attributes
        .iter()
        .find(|attribute| attribute.attr == "math_font" && attribute.value == "bold")
        .expect("lower attributes should expose stable attribute entries");
    assert_eq!(math_font.consumed.declaratives, 1);
    assert!(math_font.emitted.prefixes > 0);

    let json = serde_json::to_value(&dto).expect("report DTO should serialize");
    assert!(json.get("rules").is_some());
    assert!(json.get("applied").is_none());
    assert!(json["rules"][0].get("applied_count").is_some());
    assert!(json["rules"][0].get("count").is_none());
    assert!(json["flatten_groups"].get("actions").is_some());
    assert!(json["flatten_groups"].get("guards").is_some());
    assert!(
        json["flatten_groups"]
            .get("preserved_group_containing_declarative_command")
            .is_none()
    );
    assert!(
        json["flatten_groups"]["guards"]
            .get("preserve_group_containing_declarative_command")
            .is_some()
    );
    assert!(json["lower_attributes"].get("attributes").is_some());
}