Skip to main content

workshop_rs/
detect.rs

1//! Workshop client-language detection and explicit locale override.
2//!
3//! Detection scores each catalog-declared locale by how many distinct
4//! localized builtin/struct/enum aliases appear in the input text. With the
5//! v0.2 en-US catalog this confirms English Workshop input; adding locales is
6//! a data-pipeline change that makes cross-locale ranking meaningful.
7//! Ambiguous or low-confidence input fails explicitly rather than selecting
8//! arbitrarily, and an explicit locale always bypasses detection.
9
10use crate::catalog::{Catalog, Locale};
11use crate::error::{Result, WorkshopError};
12
13/// A language-detection result with ranked evidence.
14#[derive(Debug, Clone, PartialEq)]
15pub struct Detection {
16    /// The best-matching locale.
17    pub locale: Locale,
18    /// Confidence in `[0, 1)`; grows with the number of distinct matches.
19    pub confidence: f64,
20    /// Distinct catalog aliases found for the best locale.
21    pub matches: usize,
22    /// Every candidate locale with its match count, ranked descending.
23    pub candidates: Vec<(Locale, usize)>,
24}
25
26/// The minimum distinct-match count required to trust a detection.
27pub const MIN_MATCHES: usize = 2;
28
29/// Detect the Workshop client language of the input.
30pub fn detect(input: &str, catalog: &Catalog) -> Detection {
31    let mut candidates: Vec<(Locale, usize)> = catalog
32        .locales()
33        .iter()
34        .map(|locale| {
35            let matches = locale_alias_matches(input, catalog, locale);
36            (locale.clone(), matches)
37        })
38        .collect();
39    candidates.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
40
41    let (locale, matches) = candidates
42        .first()
43        .cloned()
44        .unwrap_or_else(|| (Locale::new("en-US"), 0));
45    let confidence = matches as f64 / (matches as f64 + 1.0);
46    Detection {
47        locale,
48        confidence,
49        matches,
50        candidates,
51    }
52}
53
54/// Resolve a locale for parsing: an explicit override always wins; otherwise
55/// auto-detect and require a confident, unambiguous match.
56pub fn resolve_locale(
57    input: &str,
58    catalog: &Catalog,
59    override_locale: Option<&Locale>,
60) -> Result<Locale> {
61    if let Some(locale) = override_locale {
62        if !catalog.supports(locale) {
63            return Err(WorkshopError::Unknown {
64                kind: "locale",
65                spelling: locale.to_string(),
66                locale: locale.clone(),
67                span: None,
68            });
69        }
70        return Ok(locale.clone());
71    }
72    let detection = detect(input, catalog);
73    if detection.matches == 0 {
74        return Err(WorkshopError::Unknown {
75            kind: "language",
76            spelling: "<none>".to_string(),
77            locale: detection.locale,
78            span: None,
79        });
80    }
81    if detection.matches < MIN_MATCHES {
82        return Err(WorkshopError::Unsupported {
83            message: format!(
84                "insufficient evidence to detect the Workshop client language ({} distinct match(es))",
85                detection.matches
86            ),
87            span: None,
88        });
89    }
90    if detection.candidates.len() > 1
91        && detection.candidates[0].1 == detection.candidates[1].1
92        && detection.candidates[0].1 > 0
93    {
94        return Err(WorkshopError::Unsupported {
95            message: "ambiguous Workshop client language: multiple locales tie".to_string(),
96            span: None,
97        });
98    }
99    Ok(detection.locale)
100}
101
102/// Count distinct catalog aliases of `locale` that appear in the input.
103fn locale_alias_matches(input: &str, catalog: &Catalog, locale: &Locale) -> usize {
104    let mut matches = 0usize;
105    for kind in [
106        crate::catalog::Kind::Structural,
107        crate::catalog::Kind::Action,
108        crate::catalog::Kind::Value,
109        crate::catalog::Kind::Event,
110        crate::catalog::Kind::Operator,
111    ] {
112        for entry in catalog.entries_of(kind) {
113            if let Some(spelling) = entry.spelling(locale) {
114                if contains_word(input, spelling) {
115                    matches += 1;
116                }
117            }
118        }
119    }
120    // Enum member spellings (e.g. "Grapple Beam", "Ignore Condition").
121    for domain in catalog.enum_domains() {
122        for member in &domain.members {
123            if let Some(spelling) = member.spelling(locale) {
124                if contains_word(input, spelling) {
125                    matches += 1;
126                }
127            }
128        }
129    }
130    matches
131}
132
133/// Whether `needle` appears in `haystack` bounded by non-word characters.
134fn contains_word(haystack: &str, needle: &str) -> bool {
135    if needle.is_empty() {
136        return false;
137    }
138    for (start, _) in haystack.match_indices(needle) {
139        let end = start + needle.len();
140        let before_ok = start == 0
141            || !haystack[..start]
142                .chars()
143                .next_back()
144                .is_some_and(is_word_char);
145        let after_ok =
146            end >= haystack.len() || !haystack[end..].chars().next().is_some_and(is_word_char);
147        if before_ok && after_ok {
148            return true;
149        }
150    }
151    false
152}
153
154fn is_word_char(ch: char) -> bool {
155    ch.is_alphanumeric() || ch == '_' || ch == '-'
156}