1use crate::{
2 constants, static_data::spec_json, utils, CodePoint, CodePointsSpecs, CollapsedEnsNameToken,
3 CurrableError, DisallowedSequence, EnsNameToken, ParsedGroup, ProcessError, TokenizedLabel,
4 TokenizedName,
5};
6use itertools::Itertools;
7use std::collections::HashSet;
8pub type LabelType = spec_json::GroupName;
9
10#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct ValidatedLabel {
14 pub tokens: Vec<EnsNameToken>,
15 pub label_type: LabelType,
16}
17
18pub fn validate_name(
19 name: &TokenizedName,
20 specs: &CodePointsSpecs,
21) -> Result<Vec<ValidatedLabel>, ProcessError> {
22 if name.is_empty() {
23 return Ok(vec![]);
24 }
25 let labels = name
26 .iter_labels()
27 .map(|label| validate_label(label, specs))
28 .collect::<Result<Vec<_>, _>>()?;
29 Ok(labels)
30}
31
32pub fn validate_label(
35 label: TokenizedLabel<'_>,
36 specs: &CodePointsSpecs,
37) -> Result<ValidatedLabel, ProcessError> {
38 non_empty(&label)?;
39 check_token_types(&label)?;
40 if label.is_fully_emoji() {
41 return Ok(ValidatedLabel {
42 tokens: label.tokens.to_owned(),
43 label_type: LabelType::Emoji,
44 });
45 };
46 underscore_only_at_beginning(&label)?;
47 if label.is_fully_ascii() {
48 no_hyphen_at_second_and_third(&label)?;
49 return Ok(ValidatedLabel {
50 tokens: label.tokens.to_owned(),
51 label_type: LabelType::Ascii,
52 });
53 }
54 check_fenced(&label, specs)?;
55 check_cm_leading_emoji(&label, specs)?;
56 let group = check_and_get_group(&label, specs)?;
57 Ok(ValidatedLabel {
58 tokens: label.tokens.to_owned(),
59 label_type: group.name,
60 })
61}
62
63fn non_empty(label: &TokenizedLabel) -> Result<(), ProcessError> {
64 let non_ignored_token_exists = label.tokens.iter().any(|token| !token.is_ignored());
65 if !non_ignored_token_exists {
66 return Err(ProcessError::DisallowedSequence(
67 DisallowedSequence::EmptyLabel,
68 ));
69 }
70 Ok(())
71}
72
73fn check_token_types(label: &TokenizedLabel) -> Result<(), ProcessError> {
74 if let Some(token) = label
75 .tokens
76 .iter()
77 .find(|token| token.is_disallowed() || token.is_stop())
78 {
79 let cps = token.cps();
80 let maybe_invisible_cp = cps.iter().find(|cp| {
81 *cp == &constants::CP_ZERO_WIDTH_JOINER || *cp == &constants::CP_ZERO_WIDTH_NON_JOINER
82 });
83 if let Some(invisible_cp) = maybe_invisible_cp {
84 return Err(ProcessError::DisallowedSequence(
85 DisallowedSequence::InvisibleCharacter(*invisible_cp),
86 ));
87 } else {
88 return Err(ProcessError::DisallowedSequence(
89 DisallowedSequence::Invalid(utils::cps2str(&cps)),
90 ));
91 }
92 }
93 Ok(())
94}
95
96fn underscore_only_at_beginning(label: &TokenizedLabel) -> Result<(), ProcessError> {
97 let leading_underscores = label
98 .iter_cps()
99 .take_while(|cp| *cp == constants::CP_UNDERSCORE)
100 .count();
101 let underscore_in_middle = label
102 .iter_cps()
103 .enumerate()
104 .skip(leading_underscores)
105 .find(|(_, cp)| *cp == constants::CP_UNDERSCORE);
106 if let Some((index, _)) = underscore_in_middle {
107 return Err(ProcessError::CurrableError {
108 inner: CurrableError::UnderscoreInMiddle,
109 index,
110 sequence: utils::cps2str(&[constants::CP_UNDERSCORE]),
111 maybe_suggest: Some("".to_string()),
112 });
113 }
114 Ok(())
115}
116
117fn no_hyphen_at_second_and_third(label: &TokenizedLabel) -> Result<(), ProcessError> {
121 if label.iter_cps().nth(2) == Some(constants::CP_HYPHEN)
122 && label.iter_cps().nth(3) == Some(constants::CP_HYPHEN)
123 {
124 return Err(ProcessError::CurrableError {
125 inner: CurrableError::HyphenAtSecondAndThird,
126 index: 2,
127 sequence: utils::cps2str(&[constants::CP_HYPHEN, constants::CP_HYPHEN]),
128 maybe_suggest: Some("".to_string()),
129 });
130 }
131 Ok(())
132}
133
134fn check_fenced(label: &TokenizedLabel, specs: &CodePointsSpecs) -> Result<(), ProcessError> {
135 if let Some(first_cp) = label.iter_cps().next() {
136 if specs.is_fenced(first_cp) {
137 return Err(ProcessError::CurrableError {
138 inner: CurrableError::FencedLeading,
139 index: 0,
140 sequence: utils::cps2str(&[first_cp]),
141 maybe_suggest: Some("".to_string()),
142 });
143 }
144 }
145 if let Some(last_cp) = label.iter_cps().last() {
146 if specs.is_fenced(last_cp) {
147 return Err(ProcessError::CurrableError {
148 inner: CurrableError::FencedTrailing,
149 index: label.iter_cps().count() - 1,
150 sequence: utils::cps2str(&[last_cp]),
151 maybe_suggest: Some("".to_string()),
152 });
153 }
154 }
155
156 for (i, window) in label.iter_cps().tuple_windows().enumerate() {
157 let (one, two) = window;
158 if specs.is_fenced(one) && specs.is_fenced(two) {
159 return Err(ProcessError::CurrableError {
160 inner: CurrableError::FencedConsecutive,
161 index: i,
162 sequence: utils::cps2str(&[one, two]),
163 maybe_suggest: Some(utils::cp2str(one)),
164 });
165 }
166 }
167 Ok(())
168}
169
170fn check_cm_leading_emoji(
171 label: &TokenizedLabel,
172 specs: &CodePointsSpecs,
173) -> Result<(), ProcessError> {
174 let mut index = 0;
175 let collapsed = label.collapse_into_text_or_emoji();
176 for (i, token) in collapsed.iter().enumerate() {
177 if let CollapsedEnsNameToken::Text(token) = token {
178 if let Some(cp) = token.cps.first() {
179 if specs.is_cm(*cp) {
180 if i == 0 {
181 return Err(ProcessError::CurrableError {
182 inner: CurrableError::CmStart,
183 index,
184 sequence: utils::cps2str(&[*cp]),
185 maybe_suggest: Some("".to_string()),
186 });
187 } else {
188 return Err(ProcessError::CurrableError {
189 inner: CurrableError::CmAfterEmoji,
190 index,
191 sequence: utils::cps2str(&[*cp]),
192 maybe_suggest: Some("".to_string()),
193 });
194 }
195 }
196 }
197 }
198 index += token.input_size();
199 }
200
201 Ok(())
202}
203
204fn check_and_get_group(
205 label: &TokenizedLabel,
206 specs: &CodePointsSpecs,
207) -> Result<ParsedGroup, ProcessError> {
208 let cps = label.get_cps_of_not_ignored_text();
209 let unique_cps = cps
210 .clone()
211 .into_iter()
212 .collect::<HashSet<_>>()
213 .into_iter()
214 .collect::<Vec<_>>();
215 let group = determine_group(&unique_cps, specs).cloned()?;
216 check_group(&group, &cps, specs)?;
217 check_whole(&group, &unique_cps, specs)?;
218 Ok(group)
219}
220
221fn check_group(
222 group: &ParsedGroup,
223 cps: &[CodePoint],
224 specs: &CodePointsSpecs,
225) -> Result<(), ProcessError> {
226 for cp in cps.iter() {
227 if !group.contains_cp(*cp) {
228 return Err(ProcessError::Confused(format!(
229 "symbol {} not present in group {}",
230 utils::cp2str(*cp),
231 group.name
232 )));
233 }
234 }
235 if group.cm_absent {
236 let decomposed = utils::nfd_cps(cps, specs);
237 let mut i = 1;
238 let e = decomposed.len();
239 while i < e {
240 if specs.is_nsm(decomposed[i]) {
241 let mut j = i + 1;
242 while j < e && specs.is_nsm(decomposed[j]) {
243 if j - i + 1 > specs.nsm_max() as usize {
244 return Err(ProcessError::DisallowedSequence(
245 DisallowedSequence::NsmTooMany,
246 ));
247 }
248 for k in i..j {
249 if decomposed[k] == decomposed[j] {
250 return Err(ProcessError::DisallowedSequence(
251 DisallowedSequence::NsmRepeated,
252 ));
253 }
254 }
255 j += 1;
256 }
257 i = j;
258 }
259 i += 1;
260 }
261 }
262 Ok(())
263}
264
265fn check_whole(
266 group: &ParsedGroup,
267 unique_cps: &[CodePoint],
268 specs: &CodePointsSpecs,
269) -> Result<(), ProcessError> {
270 let mut shared = Vec::new();
271 let mut universe = Vec::new();
272 let mut universe_len = 0usize;
273
274 for &cp in unique_cps {
275 if let Some(whole) = specs.whole_for_confusable(cp) {
276 let complements = whole
277 .complements
278 .get(&cp)
279 .expect("confusable codepoint must have complements");
280
281 if universe_len == 0 {
282 universe_len = complements.len();
283 universe = complements.to_vec();
284 } else {
285 let mut next = 0;
286 for i in 0..universe_len {
287 if complements.binary_search(&universe[i]).is_ok() {
288 universe[next] = universe[i];
289 next += 1;
290 }
291 }
292 universe_len = next;
293 universe.truncate(universe_len);
294 }
295
296 if universe_len == 0 {
297 return Ok(());
298 }
299 } else if specs.is_unique_non_confusable(cp) {
300 return Ok(());
301 } else {
302 shared.push(cp);
303 }
304 }
305
306 if universe_len > 0 {
307 for &group_idx in &universe[..universe_len] {
308 let other = specs.group_at(group_idx);
309 if other.contains_all_cps(&shared) {
310 return Err(ProcessError::ConfusedGroups {
311 group1: group.name.to_string(),
312 group2: other.name.to_string(),
313 });
314 }
315 }
316 }
317
318 Ok(())
319}
320
321fn determine_group<'a>(
322 unique_cps: &'a [CodePoint],
323 specs: &'a CodePointsSpecs,
324) -> Result<&'a ParsedGroup, ProcessError> {
325 specs
326 .groups_for_cps(unique_cps)
327 .next()
328 .ok_or(ProcessError::Confused(format!(
329 "no group found for {:?}",
330 unique_cps
331 )))
332}
333
334#[cfg(test)]
335mod tests {
336 use crate::TokenizedName;
337
338 use super::*;
339 use pretty_assertions::assert_eq;
340 use rstest::{fixture, rstest};
341
342 #[fixture]
343 #[once]
344 fn specs() -> CodePointsSpecs {
345 CodePointsSpecs::default()
346 }
347
348 #[rstest]
349 #[case::hello("hello", Ok(LabelType::Ascii))]
351 #[case::latin("E︎̃", Ok(LabelType::Other("Latin".to_string())))]
352 #[case::cyrillic("всем-привет", Ok(LabelType::Other("Cyrillic".to_string())))]
353 #[case::with_fenced_in_middle("a・a’s", Ok(LabelType::Other("Han".to_string())))]
354 #[case::ascii_with_hyphen("ab-c", Ok(LabelType::Ascii))]
355 #[case::hyphen_at_second_and_third("ab--", Err(ProcessError::CurrableError {
357 inner: CurrableError::HyphenAtSecondAndThird,
358 index: 2,
359 sequence: "--".to_string(),
360 maybe_suggest: Some("".to_string())
361 }))]
362 #[case::fenced_leading("’85", Err(ProcessError::CurrableError {
363 inner: CurrableError::FencedLeading,
364 index: 0,
365 sequence: "’".to_string(),
366 maybe_suggest: Some("".to_string())
367 }))]
368 #[case::fenced_contiguous("a・・a", Err(ProcessError::CurrableError {
369 inner: CurrableError::FencedConsecutive,
370 index: 1,
371 sequence: "・・".to_string(),
372 maybe_suggest: Some("・".to_string())
373 }))]
374 #[case::cm_after_emoji("😎😎😎😎😎😎😎😎\u{300}hello", Err(ProcessError::CurrableError {
375 inner: CurrableError::CmAfterEmoji,
376 index: 8,
377 sequence: "\u{300}".to_string(),
378 maybe_suggest: Some("".to_string())
379 }))]
380 #[case::cm_leading("\u{300}hello", Err(ProcessError::CurrableError {
381 inner: CurrableError::CmStart,
382 index: 0,
383 sequence: "\u{300}".to_string(),
384 maybe_suggest: Some("".to_string())
385 }))]
386 fn test_validate_and_get_type(
387 #[case] input: &str,
388 #[case] expected: Result<LabelType, ProcessError>,
389 specs: &CodePointsSpecs,
390 ) {
391 let name = TokenizedName::from_input(input, specs, true).unwrap();
392 let label = name.iter_labels().next().unwrap();
393 let result = validate_label(label, specs);
394 assert_eq!(
395 result.clone().map(|v| v.label_type),
396 expected,
397 "{:?}",
398 result
399 );
400 }
401
402 #[rstest]
403 #[case::emoji("\"Emoji\"", LabelType::Emoji)]
404 #[case::ascii("\"ASCII\"", LabelType::Ascii)]
405 #[case::greek("\"Greek\"", LabelType::Greek)]
406 #[case::other("\"FooBar\"", LabelType::Other("FooBar".to_string()))]
407 fn test_deserialize_label_type(#[case] input: &str, #[case] expected: LabelType) {
408 let result: LabelType = serde_json::from_str(input).unwrap();
409 assert_eq!(result, expected);
410 }
411}