Skip to main content

workshop_rs/catalog/
detect.rs

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