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 contains_word(input, spelling) {
106                    matches += 1;
107                }
108            }
109        }
110    }
111    // Enum member spellings (e.g. "Grapple Beam", "Ignore Condition").
112    for domain in catalog.enum_domains() {
113        for member in &domain.members {
114            if let Some(spelling) = member.spelling(locale) {
115                if contains_word(input, spelling) {
116                    matches += 1;
117                }
118            }
119        }
120    }
121    matches
122}
123
124/// Whether `needle` appears in `haystack` bounded by non-word characters.
125fn contains_word(haystack: &str, needle: &str) -> bool {
126    if needle.is_empty() {
127        return false;
128    }
129    for (start, _) in haystack.match_indices(needle) {
130        let end = start + needle.len();
131        let before_ok = start == 0
132            || !haystack[..start]
133                .chars()
134                .next_back()
135                .is_some_and(is_word_char);
136        let after_ok =
137            end >= haystack.len() || !haystack[end..].chars().next().is_some_and(is_word_char);
138        if before_ok && after_ok {
139            return true;
140        }
141    }
142    false
143}
144
145fn is_word_char(ch: char) -> bool {
146    ch.is_alphanumeric() || ch == '_' || ch == '-'
147}