opy-rs 0.1.61

Standalone OverPy-compatible .opy implementation with parsing, tooling, and bounded Workshop compilation.
Documentation
//! Numeric range setting coverage.

use std::path::{Path, PathBuf};

use crate::hir::Expr;
use crate::{CompileFailureClass, CompileStatus, Compiler};
use workshop_rs::catalog::Locale;
use workshop_rs::{Action, Value};

fn find_call<'a>(value: &'a Value, name: &str) -> Option<&'a Value> {
    if let Value::Call {
        name: value_name,
        args,
    } = value
    {
        if value_name == name {
            return Some(value);
        }
        return args.iter().find_map(|arg| find_call(arg, name));
    }
    None
}

fn action_values(action: &Action) -> Vec<&Value> {
    match action {
        Action::SetGlobalVariable { value, .. }
        | Action::ModifyGlobalVariable { value, .. }
        | Action::AssignMember { value, .. }
        | Action::If { condition: value }
        | Action::ElseIf { condition: value }
        | Action::While { condition: value } => vec![value],
        Action::SetPlayerVariable { player, value, .. }
        | Action::ModifyPlayerVariable { player, value, .. } => vec![player, value],
        Action::ForGlobalVariable {
            start, stop, step, ..
        } => vec![start, stop, step],
        Action::ForPlayerVariable {
            player,
            start,
            stop,
            step,
            ..
        } => {
            vec![player, start, stop, step]
        }
        Action::Call { args, .. } => args.iter().collect(),
        Action::CallSubroutine { .. } | Action::Else | Action::End => Vec::new(),
        Action::Disabled { action } => action_values(action),
    }
}

fn fixture_dir() -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR"))
        .join("tests/fixtures/corpus/real-world/overpy-broken-weapons")
}

#[test]
fn minimized_numeric_range_reaches_canonical_setting_value() {
    let dir = fixture_dir();
    let source = std::fs::read_to_string(dir.join("regressions/numeric-range-setting.opy"))
        .expect("minimized regression must be readable");
    let compiler = Compiler::new().expect("released Workshop contract must load");
    let artifact = compiler
        .compile_source_with_locale(
            &source,
            "regressions/numeric-range-setting.opy",
            &dir,
            &Locale::new("en-US"),
        )
        .expect("numeric range setting must lower");

    let program = super::canonical_program(&artifact);
    let setting = program
        .rules
        .iter()
        .flat_map(|rule| rule.actions.iter())
        .flat_map(action_values)
        .find_map(|value| find_call(value, "createWorkshopSettingFloat"))
        .expect("global initializer must contain the canonical setting value");
    let Value::Call { name, args } = setting else {
        unreachable!("the search above only returns call values");
    };
    assert_eq!(name, "createWorkshopSettingFloat");
    assert_eq!(args.len(), 6);
    assert!(matches!(
        &args[0],
        Value::String(value) if value == "\u{3000}"
    ));
    assert!(matches!(
        &args[3],
        Value::Number(value) if *value == 0.5
    ));
    assert!(matches!(
        &args[4],
        Value::Number(value) if *value == 10.0
    ));
}

#[test]
fn numeric_range_preserves_hir_type_span_and_round_trip() {
    let dir = fixture_dir();
    let source = std::fs::read_to_string(dir.join("regressions/numeric-range-setting.opy"))
        .expect("minimized regression must be readable");
    let hir = crate::compile(&source, "numeric-range-setting.opy", &dir)
        .expect("numeric range source must resolve");
    hir.validate().expect("numeric range HIR must validate");
    let round_trip = crate::hir::parse_value(
        serde_json::to_value(&hir).expect("numeric range HIR must serialize"),
    )
    .expect("numeric range HIR must round-trip");
    round_trip
        .validate()
        .expect("round-tripped HIR must validate");
    assert_eq!(hir.dump(), round_trip.dump());

    let Expr::Call { args, .. } = find_initializer(&hir) else {
        panic!("expected createWorkshopSetting initializer");
    };
    let Expr::Type { name, args, span } = &args[0] else {
        panic!("expected a type literal as the first setting argument");
    };
    assert_eq!(name, "float");
    assert_eq!(args.len(), 2);
    assert_eq!(span.unwrap().start.line, 1);
    assert_eq!(span.unwrap().start.col, 46);
    assert_eq!(span.unwrap().end.col, 59);
}

#[test]
fn malformed_numeric_range_keeps_frontend_diagnostic_boundary() {
    let source = "globalvar value = createWorkshopSetting(float[0.5:10, \"\", \"name\", 1, 0)\n";
    let report = Compiler::new()
        .expect("released Workshop contract must load")
        .compile_source_report_with_locale(
            source,
            "numeric-range-invalid.opy",
            Path::new("."),
            &Locale::new("en-US"),
        );
    assert_eq!(report.compile.status, CompileStatus::Failure);
    assert_eq!(
        report.compile.failure_class,
        Some(CompileFailureClass::Frontend)
    );
    assert_eq!(report.compile.diagnostics[0].code, "parse-error");
}

#[test]
fn motivating_project_compiles_after_numeric_range_and_return_support() {
    let dir = fixture_dir();
    let source = std::fs::read_to_string(dir.join("broken_weapons.opy"))
        .expect("motivating project must be readable");
    let report = Compiler::new()
        .expect("released Workshop contract must load")
        .compile_source_report_with_locale(
            &source,
            "broken_weapons.opy",
            &dir,
            &Locale::new("en-US"),
        );

    assert_eq!(report.compile.status, CompileStatus::Success);
    assert!(report.compile.diagnostics.is_empty());
}

fn find_initializer(program: &crate::hir::Program) -> &Expr {
    program
        .declarations
        .iter()
        .find_map(|declaration| match declaration {
            crate::hir::Declaration::GlobalVariable {
                initializer: Some(initializer),
                ..
            } => Some(initializer.as_ref()),
            _ => None,
        })
        .expect("global initializer must exist")
}

#[test]
fn omitted_setting_sort_order_lowers_to_zero() {
    let compiler = Compiler::new().expect("released Workshop contract must load");
    for (call, function) in [
        (
            "createWorkshopSettingBool(\"C\", \"N\", true)",
            "workshopSettingToggle",
        ),
        (
            "createWorkshopSettingInt(\"C\", \"N\", 1, 0, 10)",
            "workshopSettingInteger",
        ),
        (
            "createWorkshopSettingEnum(\"C\", \"N\", 0, [\"a\", \"b\"])",
            "workshopSettingCombo",
        ),
        (
            "createWorkshopSettingHero(\"C\", \"N\", Hero.ANA)",
            "createWorkshopSettingHero",
        ),
        (
            "createWorkshopSetting(int[0:10], \"C\", \"N\", 1)",
            "workshopSettingInteger",
        ),
    ] {
        let source = format!("globalvar g\n\nrule \"p\":\n    @Event global\n    g = {call}\n");
        let artifact = compiler
            .compile_source_with_locale(&source, "sort.opy", Path::new("."), &Locale::new("en-US"))
            .unwrap_or_else(|error| panic!("{call} must compile: {error}"));
        let setting = artifact
            .wir
            .rules
            .iter()
            .flat_map(|rule| rule.actions.iter())
            .flat_map(action_values)
            .find_map(|value| find_call(value, function))
            .unwrap_or_else(|| panic!("{call} must lower to its Workshop setting call"));
        let Value::Call { name, args } = setting else {
            unreachable!("the search above only returns call values");
        };
        assert_eq!(name, function);
        assert!(matches!(args.last(), Some(Value::Number(sort_order)) if *sort_order == 0.0));
    }
}