workshop-rs 0.1.16

Canonical multi-locale Overwatch Workshop semantic core: catalog, parser, WIR, validation, emitter.
Documentation
#![allow(dead_code)]

use sha2::{Digest, Sha256};
use workshop_rs::{
    WorkshopError,
    catalog::{Catalog, Locale},
    real_projects::{RealProjectCaseExpectation, RealProjectStage},
    semantic,
};

#[derive(Debug)]
struct SpanReport {
    file: usize,
    line: u32,
    column: u32,
    end_line: u32,
    end_column: u32,
}

pub(crate) fn cases() -> &'static [RealProjectCaseExpectation] {
    workshop_rs::real_projects::REAL_PROJECT_EXPECTATION.cases
}

pub(crate) fn source(case: &RealProjectCaseExpectation) -> (String, Locale) {
    let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(case.source_fixture);
    let bytes = std::fs::read(&path).unwrap_or_else(|error| panic!("{path:?}: {error}"));
    assert_eq!(
        format!("{:x}", Sha256::digest(&bytes)),
        case.source_sha256,
        "pinned digest mismatch for {}",
        case.id
    );
    let source = String::from_utf8(bytes).expect("artifact is UTF-8 Workshop text");
    (source, Locale::new(case.locale))
}

pub(crate) fn target_locale(source_locale: &Locale) -> Locale {
    if source_locale.as_str() == "en-US" {
        Locale::new("zh-CN")
    } else {
        Locale::new("en-US")
    }
}

pub(crate) fn assert_residual_policy(
    case: &RealProjectCaseExpectation,
    stage: &str,
    issues: &[semantic::SemanticIssue],
) {
    if !issues.is_empty() {
        println!(
            "real-project={} stage={stage} residual-groups={}",
            case.id,
            serde_json::to_string(&residual_groups(issues)).expect("residual JSON")
        );
    }
    let unexpected: Vec<_> = issues
        .iter()
        .filter(|issue| !case.admits_residual(issue))
        .collect();
    assert!(
        unexpected.is_empty(),
        "{} {stage} has unexplained semantic residuals: {}",
        case.id,
        serde_json::to_string(&residual_groups(
            &unexpected.into_iter().cloned().collect::<Vec<_>>(),
        ))
        .expect("residual JSON")
    );
}

pub(crate) fn assert_gap(
    case: &RealProjectCaseExpectation,
    stage: RealProjectStage,
    error: &WorkshopError,
) {
    assert!(
        case.admits_gap(stage, error),
        "{} {} failed outside known gaps: {error:?}",
        case.id,
        stage.as_str()
    );
}

fn residual_groups(issues: &[semantic::SemanticIssue]) -> Vec<serde_json::Value> {
    let mut groups = std::collections::BTreeMap::<
        (String, String),
        (usize, Option<SpanReport>, semantic::ResidualClassification),
    >::new();
    for issue in issues {
        let entry = groups
            .entry((format!("{:?}", issue.kind), issue.name.clone()))
            .or_insert((0, None, issue.classification));
        entry.0 += 1;
        if entry.1.is_none() {
            entry.1 = issue.span.map(|span| SpanReport {
                file: span.file.index(),
                line: span.start.line,
                column: span.start.col,
                end_line: span.end.line,
                end_column: span.end.col,
            });
        }
    }
    groups
        .into_iter()
        .map(
            |((kind, source_canonical_name), (count, representative_span, classification))| {
                serde_json::json!({
                    "kind": kind,
                    "sourceCanonicalName": source_canonical_name,
                    "count": count,
                    "representativeSpan": representative_span.map(|span| serde_json::json!({
                        "file": span.file,
                        "line": span.line,
                        "column": span.column,
                        "endLine": span.end_line,
                        "endColumn": span.end_column,
                    })),
                    "classification": classification.as_str(),
                    "evidence": classification.evidence(),
                })
            },
        )
        .collect()
}

pub(crate) fn assert_custom_workshop_settings(case_id: &str, source: &str, target: &str) {
    let marker = match case_id {
        "ai-pve" => "自定义 AI",
        "bastion" => "自动重开时间间隔(小时)",
        _ => return,
    };
    assert!(
        source.contains(marker),
        "{case_id} fixture lacks expected custom setting marker"
    );
    assert!(
        target.contains(marker),
        "{case_id} locale conversion changed custom setting data"
    );
}

pub(crate) fn assert_target_locale_spellings(
    name: &str,
    source_locale: &Locale,
    target_locale: &Locale,
    source: &str,
    target: &str,
    canonical_dump: &str,
    catalog: &Catalog,
) {
    let canonical_uses = |identity: &str| {
        canonical_dump
            .split(|character: char| !character.is_ascii_alphanumeric())
            .any(|token| token == identity)
    };
    for kind in [
        workshop_rs::catalog::Kind::Structural,
        workshop_rs::catalog::Kind::Action,
        workshop_rs::catalog::Kind::Value,
        workshop_rs::catalog::Kind::Event,
        workshop_rs::catalog::Kind::Operator,
        workshop_rs::catalog::Kind::Setting,
    ] {
        for entry in catalog.entries_of(kind) {
            let Some(source_spelling) = entry.spelling(source_locale) else {
                continue;
            };
            let Some(target_spelling) = entry.spelling(target_locale) else {
                continue;
            };
            let used = if kind == workshop_rs::catalog::Kind::Structural {
                entry.id != "workshop" && source.contains(source_spelling)
            } else {
                canonical_uses(&entry.id)
            };
            if source_spelling != target_spelling && used {
                let target_spelling_present = target.contains(target_spelling)
                    || (matches!(entry.id.as_str(), "chaseAtRate" | "chaseOverTime")
                        && catalog
                            .spelling(
                                workshop_rs::catalog::Kind::Action,
                                target_locale,
                                if entry.id == "chaseAtRate" {
                                    "chasePlayerVariableAtRate"
                                } else {
                                    "chasePlayerVariableOverTime"
                                },
                            )
                            .is_some_and(|spelling| target.contains(spelling)));
                assert!(
                    target_spelling_present,
                    "{name} target locale omitted {kind:?} '{}' spelling '{}', source spelling '{}' remains the only evidence",
                    entry.id, target_spelling, source_spelling
                );
            }
        }
    }
    for domain in catalog.enum_domains() {
        if let (Some(source_spelling), Some(target_spelling)) = (
            domain.spelling(source_locale),
            domain.spelling(target_locale),
        ) {
            if source_spelling != target_spelling
                && canonical_uses(&domain.domain)
                && source.contains(source_spelling)
            {
                assert!(
                    target.contains(target_spelling),
                    "{name} target locale omitted enum domain '{}' spelling '{}', source spelling '{}' remains the only evidence",
                    domain.domain,
                    target_spelling,
                    source_spelling
                );
            }
        }
        for member in &domain.members {
            if let (Some(source_spelling), Some(target_spelling)) = (
                member.spelling(source_locale),
                member.spelling(target_locale),
            ) {
                if source_spelling != target_spelling
                    && canonical_uses(&member.member)
                    && source.contains(source_spelling)
                {
                    assert!(
                        target.contains(target_spelling),
                        "{name} target locale omitted enum member '{}.{}' spelling '{}', source spelling '{}' remains the only evidence",
                        domain.domain,
                        member.member,
                        target_spelling,
                        source_spelling
                    );
                }
            }
        }
    }
}