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(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 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}