texform-transform 0.1.0

Profile-based AST transform engine for TeXForm (internal; use the texform crate)
Documentation
use texform_core::parse::{
    AllowedMode, CommandItem, CommandKind, ParseContext, ParseContextBuilder,
};
use texform_knowledge::builtin::MANAGED_PACKAGE_IMPORT_ORDER;
use texform_transform::rewrite::{RuleAvailabilityFailure, all_rules};
use texform_transform::{
    BuildConfig, NormalizationLevel, NormalizationLevelSet, PackageName, PlanBuildError, Profile,
    RuleKey, RuleMeta, RuleTarget, TransformBuildError, TransformContext,
};

fn active_rule_keys(context: &TransformContext) -> Vec<RuleKey> {
    context
        .rewrite_plan()
        .rules()
        .iter()
        .map(|rule| rule.meta().key)
        .collect::<Vec<_>>()
}

#[test]
fn only_many_keeps_the_requested_rules() {
    let parse_ctx = ParseContextBuilder::default()
        .packages(&["physics"])
        .build()
        .expect("parse context should build");

    let requested = all_rules()
        .iter()
        .map(|rule| rule.meta())
        .filter(|meta| {
            meta.level == NormalizationLevel::Standard
                && meta.enabled_by_packages.contains(&PackageName::Physics)
        })
        .take(2)
        .map(|meta| meta.key)
        .collect::<Vec<_>>();
    assert_eq!(requested.len(), 2);

    let config = BuildConfig::profile(Profile::Authoring).only_rules_for_tests(requested.clone());
    let transform_ctx = TransformContext::from_build_config(config, &parse_ctx)
        .expect("transform context should build");

    let active_keys = active_rule_keys(&transform_ctx);

    assert_eq!(active_keys.len(), 2);
    assert!(requested.iter().all(|key| active_keys.contains(key)));
}

#[test]
fn build_with_disables_rules_touching_mutated_command_names() {
    let parse_ctx = ParseContextBuilder::default()
        .packages(&["physics"])
        .insert_item(CommandItem::new(
            "quantity",
            CommandKind::Prefix,
            AllowedMode::Math,
            "m",
        ))
        .build()
        .expect("parse context should build");

    let transform_ctx =
        TransformContext::from_build_config(BuildConfig::profile(Profile::Authoring), &parse_ctx)
            .expect("transform context should build");

    let active = transform_ctx
        .rewrite_plan()
        .rules()
        .iter()
        .map(|rule| rule.meta().key.to_string())
        .collect::<Vec<_>>();

    assert!(!active.iter().any(|key| key == "physics/quantity-to-qty"));
}

#[test]
fn disabling_all_rules_builds_empty_transform_context() {
    let parse_ctx = ParseContextBuilder::default()
        .packages(&["physics"])
        .build()
        .expect("parse context should build");

    let mut config = BuildConfig::profile(Profile::Authoring);
    for rule in all_rules() {
        config = config.disable_rule(rule.meta().key);
    }

    let transform_ctx = TransformContext::from_build_config(config, &parse_ctx)
        .expect("empty transform context should be a valid no-op");

    assert!(transform_ctx.rewrite_plan().rules().is_empty());
}

#[test]
fn only_does_not_bypass_profile_class_filter_and_can_return_empty_context() {
    let parse_ctx = ParseContextBuilder::default()
        .packages(&["physics"])
        .build()
        .expect("parse context should build");

    let non_equiv_rule = all_rules()
        .iter()
        .map(|rule| rule.meta())
        .find(|meta| meta.level != NormalizationLevel::Equiv)
        .expect("registry should contain a non-equiv rule")
        .key;
    let config = BuildConfig::profile(Profile::Authoring)
        .rewrite_levels(NormalizationLevelSet::EQUIV)
        .only_rule_for_tests(non_equiv_rule);
    let transform_ctx = TransformContext::from_build_config(config, &parse_ctx)
        .expect("empty transform context should be a valid no-op");

    assert!(transform_ctx.rewrite_plan().rules().is_empty());
}

#[test]
fn build_with_all_rules_filtered_by_packages_returns_empty_context() {
    let parse_ctx = ParseContext::empty();
    let context =
        TransformContext::from_build_config(BuildConfig::profile(Profile::Authoring), &parse_ctx)
            .expect("empty package context should produce a no-op transform context");

    let plan = context.rewrite_plan();
    assert!(plan.rules().is_empty());
    assert!(plan.eliminated_forms().is_empty());
}

#[test]
fn build_with_keeps_only_rules_enabled_by_parse_context_packages() {
    let parse_ctx = ParseContext::from_packages(&["base"]);

    let transform_ctx =
        TransformContext::from_build_config(BuildConfig::profile(Profile::Authoring), &parse_ctx)
            .expect("transform context should build");

    let active = transform_ctx
        .rewrite_plan()
        .rules()
        .iter()
        .map(|rule| rule.meta())
        .collect::<Vec<_>>();

    assert!(!active.is_empty());
    assert!(
        active
            .iter()
            .all(|meta| meta.enabled_by_packages.contains(&PackageName::Base))
    );
    assert!(
        !active
            .iter()
            .any(|meta| meta.key.to_string() == "physics/quantity-to-qty")
    );
}

#[test]
fn only_rule_reports_error_when_required_package_is_disabled() {
    let parse_ctx = ParseContext::from_packages(&["base"]);
    let physics_rule = all_rules()
        .iter()
        .find(|rule| rule.meta().key.to_string() == "physics/quantity-to-qty")
        .expect("physics quantity rule should be registered");

    let config =
        BuildConfig::profile(Profile::Authoring).only_rule_for_tests(physics_rule.meta().key);
    let error = match TransformContext::from_build_config(config, &parse_ctx) {
        Ok(_) => panic!("only physics rule should be unavailable in base-only context"),
        Err(error) => error,
    };

    assert_eq!(
        error,
        TransformBuildError::Rewrite(PlanBuildError::SelectedRuleUnavailable {
            rule: physics_rule.meta().key,
            reason: RuleAvailabilityFailure::DisabledByPackage {
                required: vec![PackageName::Physics],
                active: vec![PackageName::Base],
            },
        })
    );
}

#[test]
fn rule_metadata_enabled_packages_match_consumed_target_signatures() {
    for rule in all_rules() {
        let inferred = inferred_enabled_packages(rule.meta());
        assert_eq!(
            inferred,
            rule.meta().enabled_by_packages,
            "rule {} enabled_by_packages should match packages inferred from eliminates first, touches fallback",
            rule.meta().key
        );
    }
}

#[test]
fn rule_key_package_is_first_enabled_package_by_import_order() {
    for rule in all_rules() {
        let mut enabled = rule.meta().enabled_by_packages.to_vec();
        enabled.sort_by_key(|package| package.import_order());
        assert_eq!(
            Some(rule.meta().key.package),
            enabled.first().copied(),
            "rule {} key package should be the first enabled package by import order",
            rule.meta().key
        );
    }
}

#[test]
fn rule_metadata_targets_do_not_repeat_kind_name_variants() {
    for rule in all_rules() {
        assert_unique_target_keys(
            rule.meta().consumes.eliminates,
            rule.meta().key,
            "eliminates",
        );
        assert_unique_target_keys(rule.meta().consumes.touches, rule.meta().key, "touches");
        assert_unique_target_keys(rule.meta().produces.targets, rule.meta().key, "produces");
    }
}

fn inferred_enabled_packages(meta: &RuleMeta) -> Vec<PackageName> {
    let source_targets = if !meta.consumes.eliminates.is_empty() {
        meta.consumes.eliminates
    } else {
        meta.consumes.touches
    };

    let mut packages = Vec::new();
    for target in source_targets {
        for package in packages_for_target_signature(*target) {
            if !packages.contains(&package) {
                packages.push(package);
            }
        }
    }
    packages.sort_by_key(|package| package.import_order());
    packages
}

fn packages_for_target_signature(target: RuleTarget) -> Vec<PackageName> {
    MANAGED_PACKAGE_IMPORT_ORDER
        .iter()
        .copied()
        .filter(|package| package_contains_matching_target(*package, target))
        .collect()
}

fn package_contains_matching_target(package: PackageName, target: RuleTarget) -> bool {
    let builtin = package.package();
    match target {
        RuleTarget::Command(record) => builtin.commands.iter().any(|candidate| {
            candidate.name == record.name
                && candidate.kind == record.kind
                && candidate.argspec.source == record.argspec.source
        }),
        RuleTarget::Environment(record) => builtin.environments.iter().any(|candidate| {
            candidate.name == record.name
                && candidate.argspec.source == record.argspec.source
                && candidate.body_mode == record.body_mode
        }),
        RuleTarget::Character(record) => builtin.characters.iter().any(|candidate| {
            candidate.name == record.name
                && candidate.allowed_mode == record.allowed_mode
                && candidate.unicode_value == record.unicode_value
        }),
    }
}

fn assert_unique_target_keys(targets: &[RuleTarget], key: RuleKey, field: &str) {
    let mut seen = Vec::new();
    for target in targets {
        let target_key = target.key();
        assert!(
            !seen.contains(&target_key),
            "rule {key} repeats {} target {} `{}`; keep only the first builtin record by import order",
            field,
            target_key.kind_label(),
            target_key.name
        );
        seen.push(target_key);
    }
}