workshop-rs 0.3.11

Canonical multi-locale Overwatch Workshop semantic core: catalog, parser, WIR, validation, emitter.
Documentation
use workshop_rs::source::{Position, SourceFile, Span};
use workshop_rs::{Action, Condition, Event, Program, Rule, Value, Variable};

fn catalog() -> workshop_rs::catalog::Catalog {
    workshop_rs::catalog::Catalog::builtin().expect("builtin catalog")
}

#[test]
fn program_is_constructible_without_storage_ids() {
    let predicate = Value::call("isAlive", [Value::global_variable("Target")]);
    let condition = Condition::new(predicate.clone());
    let rule = Rule::new("Linear control flow", Event::Global)
        .condition(condition)
        .action(Action::If {
            condition: predicate.clone(),
        })
        .action(Action::call("Wait", [Value::number(1.0)]))
        .action(Action::ElseIf {
            condition: Value::Bool(false),
        })
        .action(Action::Else)
        .action(Action::While {
            condition: predicate,
        })
        .action(Action::disabled(Action::call("Abort", std::iter::empty())))
        .action(Action::End);

    let mut program = Program::new();
    program.global_variable(Variable::new("Target")).rule(rule);

    assert_eq!(program.global_variables[0].name, "Target");
    assert!(matches!(program.rules[0].actions[0], Action::If { .. }));
    assert!(matches!(program.rules[0].actions[2], Action::ElseIf { .. }));
    assert!(matches!(program.rules[0].actions[3], Action::Else));
    assert!(matches!(
        program.rules[0].actions[5],
        Action::Disabled { .. }
    ));
    assert!(matches!(program.rules[0].actions[6], Action::End));
}

#[test]
fn values_and_conditions_are_composable() {
    let value = Value::call(
        "add",
        [
            Value::global_variable("Score"),
            Value::Array(vec![Value::number(1.0), Value::number(2.0)]),
        ],
    );
    let condition = Condition::new(value.clone());
    let mut program = Program::default();
    program.rule(Rule::new("Composable", Event::Global).condition(condition));

    assert!(matches!(
        &program.rules[0].conditions[0].value,
        Value::Call { name, args } if name == "add" && args.len() == 2
    ));
}

#[test]
fn public_program_is_the_raw_parse_and_emit_boundary() {
    let catalog = catalog();
    let locale = workshop_rs::catalog::Locale::new("en-US");
    let source = "rule (\"public boundary\") {\n    event { Ongoing - Global; }\n    actions { Wait(1, Ignore Condition); }\n}\n";

    let parsed = workshop_rs::parser::parse(source, &catalog, &locale).expect("parses");
    assert_eq!(
        parsed
            .source(workshop_rs::source::FileId::from_index(0))
            .unwrap()
            .text(),
        source
    );
    let rule_span = parsed.rule_span(0).expect("public rule span");
    assert!(parsed.action_span(0, 0).is_some());
    let argument_span = parsed
        .action_argument_span(0, 0, 0)
        .expect("public action argument span");
    assert!(rule_span.start.line <= argument_span.start.line);
    let edit = parsed
        .edit_source(argument_span, "2")
        .expect("validated public source edit");
    let updated = parsed
        .source(workshop_rs::source::FileId::from_index(0))
        .unwrap()
        .apply(&[edit])
        .expect("apply public source edit");
    assert!(updated.text().contains("Wait(2, Ignore Condition)"));
    parsed.validate().expect("structurally validates");
    workshop_rs::validate::validate_canonical_ids(&parsed, &catalog).expect("catalog validates");
    assert!(parsed.semantic_issues(&catalog).is_empty());
    assert!(parsed.element_count(&catalog).expect("counts").total > 0);

    let emitted = workshop_rs::emitter::emit(&parsed, &catalog, &locale).expect("emits");
    let reparsed = workshop_rs::parser::parse(&emitted, &catalog, &locale).expect("reparses");
    assert!(workshop_rs::roundtrip::equivalent(&parsed, &reparsed));

    let edited =
        workshop_rs::parser::parse(updated.text(), &catalog, &locale).expect("reparses edit");
    assert!(!workshop_rs::roundtrip::equivalent(&parsed, &edited));
}

#[test]
fn independently_constructed_program_uses_the_same_operations() {
    let catalog = catalog();
    let locale = workshop_rs::catalog::Locale::new("en-US");
    let mut program = Program::new();
    program.global_variable(Variable::new("Score")).rule(
        Rule::new("constructed", Event::Global).action(Action::SetGlobalVariable {
            variable: "Score".to_string(),
            value: Value::number(1.0),
        }),
    );

    program.validate().expect("structurally validates");
    workshop_rs::validate::validate_canonical_ids(&program, &catalog).expect("catalog validates");
    let layout =
        workshop_rs::actions::action_width(&program, &catalog, &locale, &program.rules[0].actions)
            .expect("lays out");
    assert_eq!(layout.width, 1);
    let emitted = workshop_rs::emitter::emit(&program, &catalog, &locale).expect("emits");
    assert!(emitted.contains("Set Global Variable(Score, 1)"));
}

#[test]
fn independently_constructed_program_preserves_near_integer_numbers() {
    let catalog = catalog();
    let locale = workshop_rs::catalog::Locale::new("en-US");
    let value = 1.0000000000000004;
    let mut program = Program::new();
    program.global_variable(Variable::new("Score")).rule(
        Rule::new("near integer", Event::Global).action(Action::SetGlobalVariable {
            variable: "Score".to_string(),
            value: Value::number(value),
        }),
    );

    let emitted = workshop_rs::emitter::emit(&program, &catalog, &locale).expect("emits");
    assert!(
        emitted.contains("Set Global Variable(Score, 1.0000000000000004)"),
        "non-integral values must not be emitted as integers: {emitted}"
    );
    let reparsed = workshop_rs::parser::parse(&emitted, &catalog, &locale).expect("reparses");
    assert!(workshop_rs::roundtrip::equivalent(&program, &reparsed));
}

#[test]
fn externally_constructed_program_attaches_source_and_preserves_diagnostic_span() {
    let catalog = catalog();
    let mut program = Program::new();
    program.global_variable(Variable::new("Score")).rule(
        Rule::new("external", Event::Global).action(Action::SetGlobalVariable {
            variable: "Score".to_string(),
            value: Value::call("notCanonicalValue", std::iter::empty()),
        }),
    );

    let file = program.add_file(SourceFile::with_source(
        "main.opy",
        "Score = notCanonicalValue()\n",
    ));
    let action_span = Span::new(file, Position::new(1, 1), Position::new(1, 29));
    let value_span = Span::new(file, Position::new(1, 9), Position::new(1, 28));
    program
        .set_rule_span(0, Some(action_span))
        .expect("rule span attaches");
    program
        .set_action_span(0, 0, Some(action_span))
        .expect("action span attaches");
    program
        .set_action_argument_span(0, 0, 0, Some(value_span))
        .expect("action argument span attaches");

    assert_eq!(
        program.source(file).unwrap().text(),
        "Score = notCanonicalValue()\n"
    );
    assert_eq!(program.action_span(0, 0), Some(action_span));
    assert_eq!(program.action_argument_span(0, 0, 0), Some(value_span));

    let error = workshop_rs::validate::validate_canonical_ids(&program, &catalog)
        .expect_err("unknown canonical value is rejected");
    assert!(matches!(
        error,
        workshop_rs::WorkshopError::Unknown {
            kind: "value",
            span: Some(span),
            ..
        } if span == value_span
    ));
}

#[test]
fn external_action_provenance_reaches_structural_validation() {
    let source = "Wait(1)\n";
    let action_span = Span::new(
        workshop_rs::source::FileId::from_index(0),
        Position::new(2, 1),
        Position::new(2, 5),
    );
    let argument_span = Span::new(
        workshop_rs::source::FileId::from_index(0),
        Position::new(2, 6),
        Position::new(2, 7),
    );

    let mut action_program = Program::new();
    action_program.rule(
        Rule::new("action span", Event::Global).action(Action::call("Wait", [Value::number(1.0)])),
    );
    let file = action_program.add_file(SourceFile::with_source("main.opy", source));
    let action_span = Span::new(file, action_span.start, action_span.end);
    action_program
        .set_action_span(0, 0, Some(action_span))
        .expect("action span attaches");
    assert!(matches!(
        action_program.validate(),
        Err(workshop_rs::WorkshopError::Malformed {
            span: Some(span), ..
        }) if span == action_span
    ));

    let mut argument_program = Program::new();
    argument_program.rule(
        Rule::new("argument span", Event::Global)
            .action(Action::call("Wait", [Value::number(1.0)])),
    );
    let file = argument_program.add_file(SourceFile::with_source("main.opy", source));
    let argument_span = Span::new(file, argument_span.start, argument_span.end);
    argument_program
        .set_action_argument_span(0, 0, 0, Some(argument_span))
        .expect("action argument span attaches");
    assert!(matches!(
        argument_program.validate(),
        Err(workshop_rs::WorkshopError::Malformed {
            span: Some(span), ..
        }) if span == argument_span
    ));
}

#[test]
fn provenance_attachment_rejects_foreign_files() {
    let mut program = Program::new();
    program.rule(Rule::new("external", Event::Global));
    let file = program.add_file(SourceFile::new("main.opy"));
    let foreign = Span::new(
        workshop_rs::source::FileId::from_index(file.index() + 1),
        Position::new(1, 1),
        Position::new(1, 2),
    );

    assert_eq!(
        program.set_rule_span(0, Some(foreign)),
        Err(workshop_rs::ProvenanceError::UnknownFile(foreign.file))
    );
    assert!(program.rule_span(0).is_none());
}

#[test]
fn declaration_provenance_reaches_structural_diagnostics() {
    let mut program = Program::new();
    program.global_variable(Variable::new("Score"));
    let file = program.add_file(SourceFile::with_source("main.opy", "Score\n"));
    let declaration_span = Span::new(file, Position::new(2, 1), Position::new(2, 6));

    program
        .set_global_variable_spans(0, Some(declaration_span), None)
        .expect("declaration span attaches");
    let error = program
        .validate()
        .expect_err("source range outside the document is rejected");
    assert!(matches!(
        error,
        workshop_rs::WorkshopError::Malformed {
            span: Some(span), ..
        } if span == declaration_span
    ));
}

#[test]
fn disabled_public_semantics_fail_explicitly_at_the_storage_boundary() {
    let catalog = catalog();
    let locale = workshop_rs::catalog::Locale::new("en-US");

    let mut disabled_condition = Program::new();
    disabled_condition.rule(
        Rule::new("disabled condition", Event::Global)
            .condition(Condition::disabled(Value::Bool(true))),
    );
    assert!(matches!(
        disabled_condition.validate(),
        Err(workshop_rs::WorkshopError::Unsupported { .. })
    ));

    let mut disabled_action = Program::new();
    disabled_action.rule(
        Rule::new("disabled action", Event::Global)
            .action(Action::disabled(Action::call("Wait", [Value::number(1.0)]))),
    );
    assert!(matches!(
        workshop_rs::emitter::emit(&disabled_action, &catalog, &locale),
        Err(workshop_rs::WorkshopError::Unsupported { .. })
    ));
}