workshop_rs/catalog/
detect.rs1use std::collections::HashMap;
2
3use aho_corasick::AhoCorasick;
4
5use crate::catalog::{Catalog, Kind, Locale};
6use crate::core::error::{Result, WorkshopError};
7
8#[derive(Debug, Clone, PartialEq)]
10#[non_exhaustive]
11pub struct Detection {
12 pub locale: Locale,
14 pub confidence: f64,
16 pub matches: usize,
18 pub candidates: Vec<(Locale, usize)>,
20}
21
22pub const MIN_MATCHES: usize = 2;
24
25pub 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
53pub 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 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}