Skip to main content

workshop_rs/catalog/
detect.rs

1use std::collections::HashMap;
2
3use aho_corasick::AhoCorasick;
4
5use crate::catalog::{Catalog, Kind, Locale};
6use crate::core::error::{Result, WorkshopError};
7
8/// A language-detection result with ranked evidence.
9#[derive(Debug, Clone, PartialEq)]
10#[non_exhaustive]
11pub struct Detection {
12    /// The best-matching locale.
13    pub locale: Locale,
14    /// Confidence in `[0, 1)`; grows with the number of distinct matches.
15    pub confidence: f64,
16    /// Distinct catalog aliases found for the best locale.
17    pub matches: usize,
18    /// Every candidate locale with its match count, ranked descending.
19    pub candidates: Vec<(Locale, usize)>,
20}
21
22/// The minimum distinct-match count required to trust a detection.
23pub const MIN_MATCHES: usize = 2;
24
25/// Detect the Workshop client language of the input.
26pub fn detect(input: &str, catalog: &Catalog) -> Detection {
27    let counts = catalog
28        .detection_index
29        .as_ref()
30        .expect("validated catalog")
31        .counts(input, catalog.locales().len());
32    let mut candidates: Vec<(Locale, usize)> = catalog
33        .locales()
34        .iter()
35        .enumerate()
36        .map(|(index, locale)| (locale.clone(), counts[index]))
37        .collect();
38    candidates.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
39
40    let (locale, matches) = candidates
41        .first()
42        .cloned()
43        .unwrap_or_else(|| (Locale::new("en-US"), 0));
44    let confidence = matches as f64 / (matches as f64 + 1.0);
45    Detection {
46        locale,
47        confidence,
48        matches,
49        candidates,
50    }
51}
52
53/// Resolve a locale for parsing: an explicit override always wins; otherwise
54/// auto-detect and require a confident, unambiguous match.
55pub fn resolve_locale(
56    input: &str,
57    catalog: &Catalog,
58    override_locale: Option<&Locale>,
59) -> Result<Locale> {
60    if let Some(locale) = override_locale {
61        if !catalog.supports(locale) {
62            return Err(WorkshopError::unknown(
63                "locale",
64                locale.to_string(),
65                locale.clone(),
66                None,
67            ));
68        }
69        return Ok(locale.clone());
70    }
71    let detection = detect(input, catalog);
72    if detection.matches == 0 {
73        return Err(WorkshopError::unknown(
74            "language",
75            "<none>".to_string(),
76            detection.locale,
77            None,
78        ));
79    }
80    if detection.matches < MIN_MATCHES {
81        return Err(WorkshopError::unsupported(
82            format!(
83                "insufficient evidence to detect the Workshop client language ({} distinct match(es))",
84                detection.matches
85            ),
86            None,
87        ));
88    }
89    if detection.candidates.len() > 1
90        && detection.candidates[0].1 == detection.candidates[1].1
91        && detection.candidates[0].1 > 0
92    {
93        return Err(WorkshopError::unsupported(
94            "ambiguous Workshop client language: multiple locales tie",
95            None,
96        ));
97    }
98    Ok(detection.locale)
99}
100
101#[derive(Debug, Clone)]
102pub(super) struct AliasIndex {
103    matcher: AhoCorasick,
104    locale_indices: Vec<Vec<usize>>,
105}
106
107impl AliasIndex {
108    pub(super) fn build(catalog: &Catalog) -> std::result::Result<Self, aho_corasick::BuildError> {
109        let mut patterns = Vec::new();
110        let mut by_spelling = HashMap::new();
111        let mut locale_indices: Vec<Vec<usize>> = Vec::new();
112        for (locale_index, locale) in catalog.locales().iter().enumerate() {
113            let spellings = catalog
114                .entries
115                .iter()
116                .filter(|entry| {
117                    matches!(
118                        entry.kind,
119                        Kind::Structural
120                            | Kind::Action
121                            | Kind::Value
122                            | Kind::Event
123                            | Kind::Operator
124                    )
125                })
126                .filter_map(|entry| {
127                    Some((
128                        entry.spelling(locale)?,
129                        entry.spelling(catalog.primary_locale()),
130                    ))
131                })
132                .chain(
133                    catalog
134                        .enum_domains()
135                        .flat_map(|domain| &domain.members)
136                        .filter_map(|member| {
137                            Some((
138                                member.spelling(locale)?,
139                                member.spelling(catalog.primary_locale()),
140                            ))
141                        }),
142                );
143            for (spelling, primary) in spellings {
144                if spelling.is_empty()
145                    || (locale != catalog.primary_locale() && primary == Some(spelling))
146                {
147                    continue;
148                }
149                let pattern = *by_spelling.entry(spelling).or_insert_with(|| {
150                    let index = patterns.len();
151                    patterns.push(spelling);
152                    locale_indices.push(Vec::new());
153                    index
154                });
155                locale_indices[pattern].push(locale_index);
156            }
157        }
158        Ok(Self {
159            matcher: AhoCorasick::new(patterns)?,
160            locale_indices,
161        })
162    }
163
164    fn counts(&self, input: &str, locale_count: usize) -> Vec<usize> {
165        let mut counts = vec![0; locale_count];
166        let mut seen = vec![false; self.locale_indices.len()];
167        let mut next_start = vec![0; self.locale_indices.len()];
168        for found in self.matcher.find_overlapping_iter(input) {
169            let pattern = found.pattern().as_usize();
170            if seen[pattern] || found.start() < next_start[pattern] {
171                continue;
172            }
173            // str::match_indices skips overlapping occurrences of the same spelling,
174            // even when the first occurrence fails the word-boundary check.
175            next_start[pattern] = found.end();
176            let before_ok = !input[..found.start()]
177                .chars()
178                .next_back()
179                .is_some_and(is_word_char);
180            let after_ok = !input[found.end()..]
181                .chars()
182                .next()
183                .is_some_and(is_word_char);
184            if before_ok && after_ok {
185                seen[pattern] = true;
186                for &locale in &self.locale_indices[pattern] {
187                    counts[locale] += 1;
188                }
189            }
190        }
191        counts
192    }
193}
194
195fn is_word_char(ch: char) -> bool {
196    unicode_ident::is_xid_continue(ch) || ch == '_' || ch == '-'
197}