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