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(
63                WorkshopError::unknown("locale", locale.to_string(), locale.clone(), None)
64                    .with_candidates(crate::core::suggest::nearest(
65                        locale.as_str(),
66                        catalog.locales().iter().map(Locale::as_str),
67                        crate::core::suggest::CANDIDATE_LIMIT,
68                    )),
69            );
70        }
71        return Ok(locale.clone());
72    }
73    let detection = detect(input, catalog);
74    if detection.matches == 0 {
75        return Err(WorkshopError::unknown(
76            "language",
77            "<none>".to_string(),
78            detection.locale,
79            None,
80        ));
81    }
82    if detection.matches < MIN_MATCHES {
83        return Err(WorkshopError::unsupported(
84            format!(
85                "insufficient evidence to detect the Workshop client language ({} distinct match(es))",
86                detection.matches
87            ),
88            None,
89        ));
90    }
91    if detection.candidates.len() > 1
92        && detection.candidates[0].1 == detection.candidates[1].1
93        && detection.candidates[0].1 > 0
94    {
95        return Err(WorkshopError::unsupported(
96            "ambiguous Workshop client language: multiple locales tie",
97            None,
98        ));
99    }
100    Ok(detection.locale)
101}
102
103#[derive(Debug, Clone)]
104pub(super) struct AliasIndex {
105    matcher: AhoCorasick,
106    locale_indices: Vec<Vec<usize>>,
107}
108
109impl AliasIndex {
110    pub(super) fn build(catalog: &Catalog) -> std::result::Result<Self, aho_corasick::BuildError> {
111        let mut patterns = Vec::new();
112        let mut by_spelling = HashMap::new();
113        let mut locale_indices: Vec<Vec<usize>> = Vec::new();
114        for (locale_index, locale) in catalog.locales().iter().enumerate() {
115            let spellings = catalog
116                .entries
117                .iter()
118                .filter(|entry| {
119                    matches!(
120                        entry.kind,
121                        Kind::Structural
122                            | Kind::Action
123                            | Kind::Value
124                            | Kind::Event
125                            | Kind::Operator
126                    )
127                })
128                .filter_map(|entry| {
129                    Some((
130                        entry.spelling(locale)?,
131                        entry.spelling(catalog.primary_locale()),
132                    ))
133                })
134                .chain(
135                    catalog
136                        .enum_domains()
137                        .flat_map(|domain| &domain.members)
138                        .filter_map(|member| {
139                            Some((
140                                member.spelling(locale)?,
141                                member.spelling(catalog.primary_locale()),
142                            ))
143                        }),
144                );
145            for (spelling, primary) in spellings {
146                if spelling.is_empty()
147                    || (locale != catalog.primary_locale() && primary == Some(spelling))
148                {
149                    continue;
150                }
151                let pattern = *by_spelling.entry(spelling).or_insert_with(|| {
152                    let index = patterns.len();
153                    patterns.push(spelling);
154                    locale_indices.push(Vec::new());
155                    index
156                });
157                locale_indices[pattern].push(locale_index);
158            }
159        }
160        Ok(Self {
161            matcher: AhoCorasick::new(patterns)?,
162            locale_indices,
163        })
164    }
165
166    fn counts(&self, input: &str, locale_count: usize) -> Vec<usize> {
167        let mut counts = vec![0; locale_count];
168        let mut seen = vec![false; self.locale_indices.len()];
169        let mut next_start = vec![0; self.locale_indices.len()];
170        for found in self.matcher.find_overlapping_iter(input) {
171            let pattern = found.pattern().as_usize();
172            if seen[pattern] || found.start() < next_start[pattern] {
173                continue;
174            }
175            // str::match_indices skips overlapping occurrences of the same spelling,
176            // even when the first occurrence fails the word-boundary check.
177            next_start[pattern] = found.end();
178            let before_ok = !input[..found.start()]
179                .chars()
180                .next_back()
181                .is_some_and(is_word_char);
182            let after_ok = !input[found.end()..]
183                .chars()
184                .next()
185                .is_some_and(is_word_char);
186            if before_ok && after_ok {
187                seen[pattern] = true;
188                for &locale in &self.locale_indices[pattern] {
189                    counts[locale] += 1;
190                }
191            }
192        }
193        counts
194    }
195}
196
197fn is_word_char(ch: char) -> bool {
198    unicode_ident::is_xid_continue(ch) || ch == '_' || ch == '-'
199}