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