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