opy-rs 0.1.39

Standalone OverPy-compatible .opy implementation with parsing, tooling, and bounded Workshop compilation.
Documentation
//! Settings compile-time expression coverage.

use std::collections::BTreeMap;
use std::path::Path;

use crate::hir::SettingsNode;
use crate::{CompileStatus, Compiler, compile_with_overlay};
use workshop_rs::catalog::{Catalog, Locale};
use workshop_rs::roundtrip::equivalent;
use workshop_rs::settings::{
    Applicability, SettingId, SettingScope, SettingTarget, SettingValue, definitions_by_id,
};

fn source_with_settings(settings: &str) -> String {
    format!("{settings}\nrule \"settings\":\n    @Event global\n    pass\n")
}

#[test]
fn multiline_define_string_composition_resolves_in_settings() {
    let overlay = BTreeMap::from([
        (
            "constants.opy".to_string(),
            r##"#!define DESCRIPTION \
    "hello" \
    "world"
"##
            .to_string(),
        ),
        (
            "settings.opy".to_string(),
            r##"settings {
    "main": {
        "description": DESCRIPTION
    },
    "gamemodes": {}
}"##
            .to_string(),
        ),
    ]);
    let source = "#!include \"constants.opy\"\n#!include \"settings.opy\"\nrule \"settings\":\n    @Event global\n    pass\n";
    let hir = compile_with_overlay(source, "source.opy", Path::new("."), &overlay).unwrap();
    let settings = hir.settings.as_ref().unwrap();
    let main = match &settings.children[0] {
        SettingsNode::Group { children, .. } => children,
        other => panic!("expected main group, got {other:?}"),
    };
    assert!(matches!(
        &main[0],
        SettingsNode::String { value, .. } if value == "helloworld"
    ));
}

#[test]
fn object_defines_chain_and_string_composition_resolve_before_emission() {
    let source = source_with_settings(
        r##"#!define BASE 3
#!define SCALE(value) value*2
#!define DOUBLE SCALE(BASE)
#!define TITLE "hello"
#!define FULL_TITLE TITLE + " world"
settings {
    "main": {
        "description": FULL_TITLE
    },
    "gamemodes": {},
    "heroes": {
        "allTeams": {
            "dva": {
                "health%": DOUBLE
            }
        }
    }
}"##,
    );
    let hir = crate::compile(&source, "source.opy", Path::new(".")).unwrap();
    let settings = hir.settings.as_ref().unwrap();
    let main = match &settings.children[0] {
        SettingsNode::Group { children, .. } => children,
        other => panic!("expected main group, got {other:?}"),
    };
    assert!(matches!(
        &main[0],
        SettingsNode::String { value, .. } if value == "hello world"
    ));
    let heroes = match &settings.children[2] {
        SettingsNode::Group { children, .. } => children,
        other => panic!("expected heroes group, got {other:?}"),
    };
    let all_teams = match &heroes[0] {
        SettingsNode::Group { children, .. } => children,
        other => panic!("expected allTeams group, got {other:?}"),
    };
    let dva = match &all_teams[0] {
        SettingsNode::Group { children, .. } => children,
        other => panic!("expected dva group, got {other:?}"),
    };
    assert!(matches!(
        &dva[0],
        SettingsNode::Number { value, .. } if *value == 6.0
    ));

    let artifact = Compiler::new().unwrap().compile_hir(&hir).unwrap();
    assert!(artifact.emitted.contains("Description: \"hello world\""));
    assert!(artifact.emitted.contains("Health: 6%"));
    let parsed = workshop_rs::parser::parse(
        &artifact.emitted,
        &Catalog::builtin().unwrap(),
        &Locale::new("en-US"),
    )
    .unwrap();
    assert!(equivalent(&super::canonical_program(&artifact), &parsed));
}

#[test]
fn source_function_macro_and_compile_time_math_resolve_to_a_number() {
    let source = source_with_settings(
        r##"settings {
    "gamemodes": {},
    "heroes": {
        "allTeams": {
            "ana": {
                "health%": percent(1 + 1)
            }
        }
    }
}
macro percent(value):
    100 * value"##,
    );
    let hir = crate::compile(&source, "source.opy", Path::new(".")).unwrap();
    let artifact = Compiler::new().unwrap().compile_hir(&hir).unwrap();
    assert!(artifact.emitted.contains("Health: 200%"));
}

#[test]
fn settings_expression_failure_is_source_attributed_and_frontend_classified() {
    let source = source_with_settings(
        r##"settings {
    "gamemodes": {},
    "main": {
        "description": runtime_value
    }
}"##,
    );
    let report =
        Compiler::new()
            .unwrap()
            .compile_source_report(&source, "settings.opy", Path::new("."));
    assert_eq!(report.compile.status, CompileStatus::Failure);
    assert_eq!(
        report.compile.failure_class,
        Some(crate::CompileFailureClass::Frontend)
    );
    let diagnostic = report.compile.diagnostics.first().unwrap();
    assert_eq!(diagnostic.code, "settings-expression");
    assert_eq!(diagnostic.span.as_ref().unwrap().path, "settings.opy");
    assert_eq!(diagnostic.span.as_ref().unwrap().start.line, 4);
}

#[test]
fn settings_macro_failure_keeps_the_original_value_span() {
    let source = source_with_settings(
        r##"#!define LOOP LOOP
settings {
    "gamemodes": {},
    "main": {
        "description": LOOP
    }
}"##,
    );
    let report =
        Compiler::new()
            .unwrap()
            .compile_source_report(&source, "settings.opy", Path::new("."));
    assert_eq!(report.compile.status, CompileStatus::Failure);
    let diagnostic = report.compile.diagnostics.first().unwrap();
    assert_eq!(diagnostic.code, "macro-recursion");
    assert_eq!(diagnostic.span.as_ref().unwrap().path, "settings.opy");
    assert_eq!(diagnostic.span.as_ref().unwrap().start.line, 1);
}

#[test]
fn unresolved_expression_valued_setting_is_a_frontend_error() {
    let source = "settings {\n    \"lobby\": {\n        \"modeName\": GAMEMODE_NAME\" \"GAMEMODE_VERSION,\n    },\n    \"gamemodes\": {}\n}\nrule \"r\":\n    @Event global\n    pass\n";
    let error = crate::compile(source, "source.opy", Path::new(".")).unwrap_err();
    assert_eq!(error.code, "settings-expression");
    assert_eq!(error.span.unwrap().start.line, 3);
}

#[test]
fn hero_general_settings_are_emitted_at_team_scope() {
    let source = "settings {\n    \"gamemodes\": {},\n    \"heroes\": {\n        \"team1\": {\n            \"general\": {\n                \"damageReceived%\": 50\n            }\n        }\n    }\n}\nrule \"r\":\n    @Event global\n    pass\n";
    let hir = crate::compile(source, "source.opy", Path::new(".")).expect("settings resolve");
    let artifact = Compiler::new()
        .expect("compiler loads")
        .compile_hir(&hir)
        .expect("hero general settings must lower at team scope");
    assert!(artifact.emitted.contains("Team 1 {"));
    assert!(artifact.emitted.contains("Damage Received: 50%"));
}

#[test]
fn compiled_settings_are_queryable_through_the_canonical_consumer_api() {
    let source = source_with_settings(
        r#"settings {
    "gamemodes": {},
    "lobby": {
        "spectatorSlots": 2
    }
}"#,
    );
    let artifact = Compiler::new()
        .expect("released Workshop contract must load")
        .compile_source_artifact(&source, "settings.opy", Path::new("."))
        .expect("OPY settings must compile");

    let definition = definitions_by_id(&SettingId::from("setting.lobby.spectatorSlots"))
        .next()
        .expect("canonical lobby setting");
    assert_eq!(definition.scope(), SettingScope::Lobby);
    assert_eq!(
        definition.presentation().localized_name("en-US"),
        Some("Max Spectators")
    );
    assert!(definition.provenance().reviewed);
    assert_eq!(
        definition
            .applicability(&SettingTarget::Global)
            .expect("global lobby applicability"),
        Applicability::Applicable
    );

    let occurrence = definition
        .read(
            artifact.wir.settings.as_ref().expect("compiled settings"),
            &SettingTarget::Global,
        )
        .expect("compiled setting must be readable through the typed API");
    assert_eq!(occurrence.authored, SettingValue::Number(2.0));
    assert!(artifact.emitted.contains("Max Spectators: 2"));
}

#[test]
fn extension_directives_lower_as_canonical_flags_without_authored_settings() {
    let source = "#!extension beamEffects\n#!extension projectiles\nrule \"extensions\":\n    @Event global\n    pass\n";
    let artifact = Compiler::new()
        .expect("released Workshop contract must load")
        .compile_source_artifact(source, "extensions.opy", Path::new("."))
        .expect("extension directives must compile");

    let settings = artifact.wir.settings.as_ref().expect("generated settings");
    let workshop_rs::settings::SettingsNode::Group { children, .. } = &settings.children[0] else {
        panic!(
            "expected extensions group, got {:?}",
            settings.children[0].name()
        );
    };
    assert_eq!(
        children
            .iter()
            .map(workshop_rs::settings::SettingsNode::name)
            .collect::<Vec<_>>(),
        ["beamEffects", "projectiles"]
    );
    assert_eq!(
        children[0].span().expect("first directive span").start.line,
        1
    );
    assert_eq!(
        children[1]
            .span()
            .expect("second directive span")
            .start
            .line,
        2
    );
    assert!(artifact.emitted.contains("Beam Effects"));
    assert!(artifact.emitted.contains("Projectiles"));
}

#[test]
fn extension_directives_merge_with_authored_settings_and_preserve_provenance() {
    let source = "#!extension projectiles\n#!extension beamEffects\n#!extension projectiles\nsettings {\n    \"main\": {\n        \"description\": \"authored\"\n    },\n    \"gamemodes\": {}\n}\nrule \"extensions\":\n    @Event global\n    pass\n";
    let artifact = Compiler::new()
        .expect("released Workshop contract must load")
        .compile_source_artifact(source, "extensions.opy", Path::new("."))
        .expect("extension directives must compile with authored settings");

    let settings = artifact.wir.settings.as_ref().expect("authored settings");
    let main = settings
        .children
        .iter()
        .find(|node| node.name() == "main")
        .expect("authored main settings");
    assert_eq!(main.span().expect("authored group span").start.line, 5);
    let workshop_rs::settings::SettingsNode::Group { children, .. } = main else {
        panic!("expected authored main group");
    };
    assert_eq!(
        children[0]
            .span()
            .expect("authored setting span")
            .start
            .line,
        6
    );

    let extensions = settings
        .children
        .iter()
        .find(|node| node.name() == "extensions")
        .expect("generated extensions group");
    let workshop_rs::settings::SettingsNode::Group { children, .. } = extensions else {
        panic!("expected generated extensions group");
    };
    assert_eq!(children.len(), 2);
    assert_eq!(
        children
            .iter()
            .map(workshop_rs::settings::SettingsNode::name)
            .collect::<Vec<_>>(),
        ["projectiles", "beamEffects"]
    );
    assert_eq!(children[0].span().expect("directive span").start.line, 1);
    assert!(artifact.emitted.contains("Description: \"authored\""));
    assert!(artifact.emitted.contains("Projectiles"));
}