nile_library/
validate.rs

1use crate::commands::{CommandInfo, Occurence, COMMANDS};
2use crate::parser::{FragmentContent, ParsedString};
3use serde::{Deserialize, Serialize};
4use std::collections::{BTreeMap, HashMap};
5
6#[derive(Debug, PartialEq, Copy, Clone)]
7pub enum Dialect {
8    NEWGRF,
9    GAMESCRIPT,
10    OPENTTD,
11}
12
13#[derive(Deserialize, Debug)]
14pub struct LanguageConfig {
15    pub dialect: Dialect,
16    pub cases: Vec<String>,
17    pub genders: Vec<String>,
18    pub plural_count: usize,
19}
20
21#[derive(Debug, PartialEq)]
22pub enum Severity {
23    Error,   //< translation is broken, do not commit.
24    Warning, //< translation has minor issues, but is probably better than no translation.
25}
26
27#[derive(Serialize, Debug, PartialEq)]
28pub struct ValidationError {
29    pub severity: Severity,
30    pub pos_begin: Option<usize>, //< codepoint offset in input string
31    pub pos_end: Option<usize>,
32    pub message: String,
33    pub suggestion: Option<String>,
34}
35
36#[derive(Serialize, Debug)]
37pub struct ValidationResult {
38    pub errors: Vec<ValidationError>,
39    pub normalized: Option<String>,
40}
41
42impl Dialect {
43    pub fn allow_cases(&self) -> bool {
44        *self != Self::GAMESCRIPT
45    }
46
47    pub fn allow_genders(&self) -> bool {
48        *self != Self::GAMESCRIPT
49    }
50
51    pub fn as_str(&self) -> &'static str {
52        match self {
53            Self::NEWGRF => "newgrf",
54            Self::GAMESCRIPT => "game-script",
55            Self::OPENTTD => "openttd",
56        }
57    }
58}
59
60impl TryFrom<&str> for Dialect {
61    type Error = String;
62
63    fn try_from(value: &str) -> Result<Self, Self::Error> {
64        match value {
65            "newgrf" => Ok(Dialect::NEWGRF),
66            "game-script" => Ok(Dialect::GAMESCRIPT),
67            "openttd" => Ok(Dialect::OPENTTD),
68            _ => Err(String::from("Unknown dialect")),
69        }
70    }
71}
72
73impl<'de> Deserialize<'de> for Dialect {
74    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
75    where
76        D: serde::Deserializer<'de>,
77    {
78        let string = String::deserialize(deserializer)?;
79        let value = Dialect::try_from(string.as_str());
80        value.map_err(|_| {
81            serde::de::Error::unknown_variant(
82                string.as_str(),
83                &["game-script", "newgrf", "openttd"],
84            )
85        })
86    }
87}
88
89impl Serialize for Dialect {
90    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
91    where
92        S: serde::Serializer,
93    {
94        serializer.serialize_str(self.as_str())
95    }
96}
97
98impl Serialize for Severity {
99    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
100    where
101        S: serde::Serializer,
102    {
103        serializer.serialize_str(match self {
104            Self::Error => "error",
105            Self::Warning => "warning",
106        })
107    }
108}
109
110/**
111 * Validate whether a base string is valid.
112 *
113 * @param config The language configuration of the base language. (dialect and plural form)
114 * @param base The base string to validate.
115 *
116 * @returns A normalized form of the base string for translators, and a list of error messages, if the base is invalid.
117 */
118pub fn validate_base(config: &LanguageConfig, base: &String) -> ValidationResult {
119    let mut base = match ParsedString::parse(&base) {
120        Err(err) => {
121            return ValidationResult {
122                errors: vec![ValidationError {
123                    severity: Severity::Error,
124                    pos_begin: Some(err.pos_begin),
125                    pos_end: err.pos_end,
126                    message: err.message,
127                    suggestion: None,
128                }],
129                normalized: None,
130            };
131        }
132        Ok(parsed) => parsed,
133    };
134    let errs = validate_string(&config, &base, None);
135    if errs.iter().any(|e| e.severity == Severity::Error) {
136        ValidationResult {
137            errors: errs,
138            normalized: None,
139        }
140    } else {
141        sanitize_whitespace(&mut base);
142        normalize_string(&config.dialect, &mut base);
143        ValidationResult {
144            errors: errs,
145            normalized: Some(base.compile()),
146        }
147    }
148}
149
150/**
151 * Validate whether a translation is valid for the given base string.
152 *
153 * @param config The language configuration to validate against.
154 * @param base The base string to validate against.
155 * @param case The case of the translation. Use "default" for the default case.
156 * @param translation The translation to validate.
157 *
158 * @returns A normalized form of the translation, and a list of error messages, if the translation is invalid.
159 */
160pub fn validate_translation(
161    config: &LanguageConfig,
162    base: &String,
163    case: &String,
164    translation: &String,
165) -> ValidationResult {
166    let base = match ParsedString::parse(&base) {
167        Err(_) => {
168            return ValidationResult {
169                errors: vec![ValidationError {
170                    severity: Severity::Error,
171                    pos_begin: None,
172                    pos_end: None,
173                    message: String::from("Base language text is invalid."),
174                    suggestion: Some(String::from("This is a bug; wait until it is fixed.")),
175                }],
176                normalized: None,
177            };
178        }
179        Ok(parsed) => parsed,
180    };
181    if case != "default" {
182        if !config.dialect.allow_cases() {
183            return ValidationResult {
184                errors: vec![ValidationError {
185                    severity: Severity::Error,
186                    pos_begin: None,
187                    pos_end: None,
188                    message: String::from("No cases allowed."),
189                    suggestion: None,
190                }],
191                normalized: None,
192            };
193        } else if !config.cases.contains(&case) {
194            return ValidationResult {
195                errors: vec![ValidationError {
196                    severity: Severity::Error,
197                    pos_begin: None,
198                    pos_end: None,
199                    message: format!("Unknown case '{}'.", case),
200                    suggestion: Some(format!("Known cases are: '{}'", config.cases.join("', '"))),
201                }],
202                normalized: None,
203            };
204        }
205    }
206    let mut translation = match ParsedString::parse(&translation) {
207        Err(err) => {
208            return ValidationResult {
209                errors: vec![ValidationError {
210                    severity: Severity::Error,
211                    pos_begin: Some(err.pos_begin),
212                    pos_end: err.pos_end,
213                    message: err.message,
214                    suggestion: None,
215                }],
216                normalized: None,
217            };
218        }
219        Ok(parsed) => parsed,
220    };
221    let errs = validate_string(&config, &translation, Some(&base));
222    if errs.iter().any(|e| e.severity == Severity::Error) {
223        ValidationResult {
224            errors: errs,
225            normalized: None,
226        }
227    } else {
228        sanitize_whitespace(&mut translation);
229        normalize_string(&config.dialect, &mut translation);
230        ValidationResult {
231            errors: errs,
232            normalized: Some(translation.compile()),
233        }
234    }
235}
236
237fn remove_ascii_ctrl(t: &mut String) {
238    *t = t.replace(|c| char::is_ascii_control(&c), " ");
239}
240
241fn remove_trailing_blanks(t: &mut String) {
242    t.truncate(t.trim_end().len());
243}
244
245/// Replace all ASCII control codes with blank.
246/// Remove trailing blanks at end of each line.
247fn sanitize_whitespace(parsed: &mut ParsedString) {
248    let mut is_eol = true;
249    for i in (0..parsed.fragments.len()).rev() {
250        let mut is_nl = false;
251        match &mut parsed.fragments[i].content {
252            FragmentContent::Text(t) => {
253                remove_ascii_ctrl(t);
254                if is_eol {
255                    remove_trailing_blanks(t);
256                }
257            }
258            FragmentContent::Choice(c) => {
259                for t in &mut c.choices {
260                    remove_ascii_ctrl(t);
261                }
262            }
263            FragmentContent::Command(c) => {
264                is_nl = c.name.is_empty();
265            }
266            _ => (),
267        }
268        is_eol = is_nl;
269    }
270}
271
272struct StringSignature {
273    parameters: HashMap<usize, (&'static CommandInfo<'static>, usize)>,
274    nonpositional_count: BTreeMap<String, (Occurence, usize)>,
275    // TODO track color/lineno/colorstack for positional parameters
276}
277
278fn get_signature(
279    dialect: &Dialect,
280    base: &ParsedString,
281) -> Result<StringSignature, Vec<ValidationError>> {
282    let mut errors = Vec::new();
283    let mut signature = StringSignature {
284        parameters: HashMap::new(),
285        nonpositional_count: BTreeMap::new(),
286    };
287
288    let mut pos = 0;
289    for fragment in &base.fragments {
290        if let FragmentContent::Command(cmd) = &fragment.content {
291            if let Some(info) = COMMANDS
292                .into_iter()
293                .find(|ci| ci.name == cmd.name && ci.dialects.contains(&dialect))
294            {
295                if info.parameters.is_empty() {
296                    if let Some(index) = cmd.index {
297                        errors.push(ValidationError {
298                            severity: Severity::Error,
299                            pos_begin: Some(fragment.pos_begin),
300                            pos_end: Some(fragment.pos_end),
301                            message: format!(
302                                "Command '{{{}}}' cannot have a position reference.",
303                                cmd.name
304                            ),
305                            suggestion: Some(format!("Remove '{}:'.", index)),
306                        });
307                    }
308                    let norm_name = String::from(info.get_norm_name());
309                    if let Some(existing) = signature.nonpositional_count.get_mut(&norm_name) {
310                        existing.1 += 1;
311                    } else {
312                        signature
313                            .nonpositional_count
314                            .insert(norm_name, (info.occurence.clone(), 1));
315                    }
316                } else {
317                    if let Some(index) = cmd.index {
318                        pos = index;
319                    }
320                    if let Some(existing) = signature.parameters.get_mut(&pos) {
321                        existing.1 += 1;
322                    } else {
323                        signature.parameters.insert(pos, (info, 1));
324                    }
325                    pos += 1;
326                }
327            } else {
328                errors.push(ValidationError {
329                    severity: Severity::Error,
330                    pos_begin: Some(fragment.pos_begin),
331                    pos_end: Some(fragment.pos_end),
332                    message: format!("Unknown string command '{{{}}}'.", cmd.name),
333                    suggestion: None,
334                });
335            }
336        }
337    }
338
339    if errors.is_empty() {
340        Ok(signature)
341    } else {
342        Err(errors)
343    }
344}
345
346fn validate_string(
347    config: &LanguageConfig,
348    test: &ParsedString,
349    base: Option<&ParsedString>,
350) -> Vec<ValidationError> {
351    let signature: StringSignature;
352    match get_signature(&config.dialect, base.unwrap_or(test)) {
353        Ok(sig) => signature = sig,
354        Err(msgs) => {
355            if base.is_some() {
356                return vec![ValidationError {
357                    severity: Severity::Error,
358                    pos_begin: None,
359                    pos_end: None,
360                    message: String::from("Base language text is invalid."),
361                    suggestion: Some(String::from("This is a bug; wait until it is fixed.")),
362                }];
363            } else {
364                return msgs;
365            }
366        }
367    }
368
369    let mut errors = Vec::new();
370    let mut positional_count: HashMap<usize, usize> = HashMap::new();
371    let mut nonpositional_count: BTreeMap<String, (Occurence, usize)> = BTreeMap::new();
372    let mut pos = 0;
373    let mut front = 0;
374    for fragment in &test.fragments {
375        match &fragment.content {
376            FragmentContent::Command(cmd) => {
377                let opt_expected = signature
378                    .parameters
379                    .get(&cmd.index.unwrap_or(pos))
380                    .map(|v| (*v).0);
381                let opt_info =
382                    opt_expected
383                        .filter(|ex| ex.get_norm_name() == cmd.name)
384                        .or(COMMANDS.into_iter().find(|ci| {
385                            ci.name == cmd.name && ci.dialects.contains(&config.dialect)
386                        }));
387                if let Some(info) = opt_info {
388                    if let Some(c) = &cmd.case {
389                        if !config.dialect.allow_cases() {
390                            errors.push(ValidationError {
391                                severity: Severity::Error,
392                                pos_begin: Some(fragment.pos_begin),
393                                pos_end: Some(fragment.pos_end),
394                                message: String::from("No case selections allowed."),
395                                suggestion: Some(format!("Remove '.{}'.", c)),
396                            });
397                        } else if !info.allow_case {
398                            errors.push(ValidationError {
399                                severity: Severity::Error,
400                                pos_begin: Some(fragment.pos_begin),
401                                pos_end: Some(fragment.pos_end),
402                                message: format!(
403                                    "No case selection allowed for '{{{}}}'.",
404                                    cmd.name
405                                ),
406                                suggestion: Some(format!("Remove '.{}'.", c)),
407                            });
408                        } else if !config.cases.contains(&c) {
409                            errors.push(ValidationError {
410                                severity: Severity::Error,
411                                pos_begin: Some(fragment.pos_begin),
412                                pos_end: Some(fragment.pos_end),
413                                message: format!("Unknown case '{}'.", c),
414                                suggestion: Some(format!(
415                                    "Known cases are: '{}'",
416                                    config.cases.join("', '")
417                                )),
418                            });
419                        }
420                    }
421
422                    if info.parameters.is_empty() {
423                        if let Some(index) = cmd.index {
424                            errors.push(ValidationError {
425                                severity: Severity::Error,
426                                pos_begin: Some(fragment.pos_begin),
427                                pos_end: Some(fragment.pos_end),
428                                message: format!(
429                                    "Command '{{{}}}' cannot have a position reference.",
430                                    cmd.name
431                                ),
432                                suggestion: Some(format!("Remove '{}:'.", index)),
433                            });
434                        }
435
436                        let norm_name = String::from(info.get_norm_name());
437                        if let Some(existing) = nonpositional_count.get_mut(&norm_name) {
438                            existing.1 += 1;
439                        } else {
440                            nonpositional_count.insert(norm_name, (info.occurence.clone(), 1));
441                        }
442                    } else {
443                        if let Some(index) = cmd.index {
444                            pos = index;
445                        }
446
447                        if let Some(expected) = opt_expected {
448                            if expected.get_norm_name() == info.get_norm_name() {
449                                if let Some(existing) = positional_count.get_mut(&pos) {
450                                    *existing += 1;
451                                } else {
452                                    positional_count.insert(pos, 1);
453                                }
454                            } else {
455                                errors.push(ValidationError {
456                                    severity: Severity::Error,
457                                    pos_begin: Some(fragment.pos_begin),
458                                    pos_end: Some(fragment.pos_end),
459                                    message: format!(
460                                        "Expected '{{{}:{}}}', found '{{{}}}'.",
461                                        pos, expected.name, cmd.name
462                                    ),
463                                    suggestion: None,
464                                })
465                            }
466                        } else {
467                            errors.push(ValidationError {
468                                severity: Severity::Error,
469                                pos_begin: Some(fragment.pos_begin),
470                                pos_end: Some(fragment.pos_end),
471                                message: format!(
472                                    "There is no parameter in position {}, found '{{{}}}'.",
473                                    pos, cmd.name
474                                ),
475                                suggestion: None,
476                            });
477                        }
478
479                        pos += 1;
480                    }
481                } else {
482                    errors.push(ValidationError {
483                        severity: Severity::Error,
484                        pos_begin: Some(fragment.pos_begin),
485                        pos_end: Some(fragment.pos_end),
486                        message: format!("Unknown string command '{{{}}}'.", cmd.name),
487                        suggestion: None,
488                    });
489                }
490                front = 2;
491            }
492            FragmentContent::Gender(g) => {
493                if !config.dialect.allow_genders() || config.genders.len() < 2 {
494                    errors.push(ValidationError {
495                        severity: Severity::Error,
496                        pos_begin: Some(fragment.pos_begin),
497                        pos_end: Some(fragment.pos_end),
498                        message: String::from("No gender definitions allowed."),
499                        suggestion: Some(String::from("Remove '{G=...}'.")),
500                    });
501                } else if front == 2 {
502                    errors.push(ValidationError {
503                        severity: Severity::Warning,
504                        pos_begin: Some(fragment.pos_begin),
505                        pos_end: Some(fragment.pos_end),
506                        message: String::from("Gender definitions must be at the front."),
507                        suggestion: Some(String::from(
508                            "Move '{G=...}' to the front of the translation.",
509                        )),
510                    });
511                } else if front == 1 {
512                    errors.push(ValidationError {
513                        severity: Severity::Warning,
514                        pos_begin: Some(fragment.pos_begin),
515                        pos_end: Some(fragment.pos_end),
516                        message: String::from("Duplicate gender definition."),
517                        suggestion: Some(String::from("Remove the second '{G=...}'.")),
518                    });
519                } else {
520                    front = 1;
521                    if !config.genders.contains(&g.gender) {
522                        errors.push(ValidationError {
523                            severity: Severity::Error,
524                            pos_begin: Some(fragment.pos_begin),
525                            pos_end: Some(fragment.pos_end),
526                            message: format!("Unknown gender '{}'.", g.gender),
527                            suggestion: Some(format!(
528                                "Known genders are: '{}'",
529                                config.genders.join("', '")
530                            )),
531                        });
532                    }
533                }
534            }
535            FragmentContent::Choice(cmd) => {
536                let opt_ref_pos = match cmd.name.as_str() {
537                    "P" => {
538                        if pos == 0 {
539                            None
540                        } else {
541                            Some(pos - 1)
542                        }
543                    }
544                    "G" => Some(pos),
545                    _ => panic!(),
546                };
547                let opt_ref_pos = cmd.indexref.or(opt_ref_pos);
548                if cmd.name == "G" && (!config.dialect.allow_genders() || config.genders.len() < 2)
549                {
550                    errors.push(ValidationError {
551                        severity: Severity::Error,
552                        pos_begin: Some(fragment.pos_begin),
553                        pos_end: Some(fragment.pos_end),
554                        message: String::from("No gender choices allowed."),
555                        suggestion: Some(String::from("Remove '{G ...}'.")),
556                    });
557                } else if cmd.name == "P" && config.plural_count < 2 {
558                    errors.push(ValidationError {
559                        severity: Severity::Error,
560                        pos_begin: Some(fragment.pos_begin),
561                        pos_end: Some(fragment.pos_end),
562                        message: String::from("No plural choices allowed."),
563                        suggestion: Some(String::from("Remove '{P ...}'.")),
564                    });
565                } else {
566                    match cmd.name.as_str() {
567                        "P" => {
568                            if cmd.choices.len() != config.plural_count {
569                                errors.push(ValidationError {
570                                    severity: Severity::Error,
571                                    pos_begin: Some(fragment.pos_begin),
572                                    pos_end: Some(fragment.pos_end),
573                                    message: format!(
574                                        "Expected {} plural choices, found {}.",
575                                        config.plural_count,
576                                        cmd.choices.len()
577                                    ),
578                                    suggestion: None,
579                                });
580                            }
581                        }
582                        "G" => {
583                            if cmd.choices.len() != config.genders.len() {
584                                errors.push(ValidationError {
585                                    severity: Severity::Error,
586                                    pos_begin: Some(fragment.pos_begin),
587                                    pos_end: Some(fragment.pos_end),
588                                    message: format!(
589                                        "Expected {} gender choices, found {}.",
590                                        config.genders.len(),
591                                        cmd.choices.len()
592                                    ),
593                                    suggestion: None,
594                                });
595                            }
596                        }
597                        _ => panic!(),
598                    };
599
600                    if let Some(ref_info) = opt_ref_pos
601                        .and_then(|ref_pos| signature.parameters.get(&ref_pos).map(|v| v.0))
602                    {
603                        let ref_pos = opt_ref_pos.unwrap();
604                        let ref_norm_name = ref_info.get_norm_name();
605                        let ref_subpos = match cmd.name.as_str() {
606                            "P" => cmd
607                                .indexsubref
608                                .or(ref_info.def_plural_subindex)
609                                .unwrap_or(0),
610                            "G" => cmd.indexsubref.unwrap_or(0),
611                            _ => panic!(),
612                        };
613                        if let Some(par_info) = ref_info.parameters.get(ref_subpos) {
614                            match cmd.name.as_str() {
615                                "P" => {
616                                    if !par_info.allow_plural {
617                                        errors.push(ValidationError{
618                                            severity: Severity::Error,
619                                            pos_begin: Some(fragment.pos_begin),
620                                            pos_end: Some(fragment.pos_end),
621                                            message: format!(
622                                                "'{{{}}}' references position '{}:{}', but '{{{}:{}}}' does not allow plurals.",
623                                                cmd.name, ref_pos, ref_subpos, ref_pos, ref_norm_name
624                                            ),
625                                            suggestion: None,
626                                        });
627                                    }
628                                }
629                                "G" => {
630                                    if !par_info.allow_gender {
631                                        errors.push(ValidationError{
632                                            severity: Severity::Error,
633                                            pos_begin: Some(fragment.pos_begin),
634                                            pos_end: Some(fragment.pos_end),
635                                            message: format!(
636                                                "'{{{}}}' references position '{}:{}', but '{{{}:{}}}' does not allow genders.",
637                                                cmd.name, ref_pos, ref_subpos, ref_pos, ref_norm_name
638                                            ),
639                                            suggestion: None,
640                                        });
641                                    }
642                                }
643                                _ => panic!(),
644                            };
645                        } else {
646                            errors.push(ValidationError{
647                                severity: Severity::Error,
648                                pos_begin: Some(fragment.pos_begin),
649                                pos_end: Some(fragment.pos_end),
650                                message: format!(
651                                    "'{{{}}}' references position '{}:{}', but '{{{}:{}}}' only has {} subindices.",
652                                    cmd.name, ref_pos, ref_subpos, ref_pos, ref_norm_name, ref_info.parameters.len()
653                                ),
654                                suggestion: None,
655                            });
656                        }
657                    } else {
658                        errors.push(ValidationError {
659                            severity: Severity::Error,
660                            pos_begin: Some(fragment.pos_begin),
661                            pos_end: Some(fragment.pos_end),
662                            message: format!(
663                                "'{{{}}}' references position '{}', which has no parameter.",
664                                cmd.name,
665                                opt_ref_pos
666                                    .and_then(|v| isize::try_from(v).ok())
667                                    .unwrap_or(-1)
668                            ),
669                            suggestion: if cmd.indexref.is_none() {
670                                Some(String::from("Add a position reference."))
671                            } else {
672                                None
673                            },
674                        });
675                    }
676                }
677                front = 2;
678            }
679            FragmentContent::Text(_) => {
680                front = 2;
681            }
682        }
683    }
684
685    for (pos, (info, ex_count)) in &signature.parameters {
686        let norm_name = info.get_norm_name();
687        let found_count = positional_count.get(pos).cloned().unwrap_or(0);
688        if info.occurence != Occurence::ANY && found_count == 0 {
689            errors.push(ValidationError {
690                severity: Severity::Error,
691                pos_begin: None,
692                pos_end: None,
693                message: format!("String command '{{{}:{}}}' is missing.", pos, norm_name),
694                suggestion: None,
695            });
696        } else if info.occurence == Occurence::EXACT && *ex_count != found_count {
697            errors.push(ValidationError {
698                severity: Severity::Warning,
699                pos_begin: None,
700                pos_end: None,
701                message: format!(
702                    "String command '{{{}:{}}}': expected {} times, found {} times.",
703                    pos, norm_name, ex_count, found_count
704                ),
705                suggestion: None,
706            });
707        }
708    }
709
710    for (norm_name, (occurence, ex_count)) in &signature.nonpositional_count {
711        let found_count = nonpositional_count.get(norm_name).map(|v| v.1).unwrap_or(0);
712        if *occurence != Occurence::ANY && found_count == 0 {
713            errors.push(ValidationError {
714                severity: Severity::Warning,
715                pos_begin: None,
716                pos_end: None,
717                message: format!("String command '{{{}}}' is missing.", norm_name),
718                suggestion: None,
719            });
720        } else if *occurence == Occurence::EXACT && *ex_count != found_count {
721            errors.push(ValidationError {
722                severity: Severity::Warning,
723                pos_begin: None,
724                pos_end: None,
725                message: format!(
726                    "String command '{{{}}}': expected {} times, found {} times.",
727                    norm_name, ex_count, found_count
728                ),
729                suggestion: None,
730            });
731        }
732    }
733    for (norm_name, (occurence, _)) in &nonpositional_count {
734        if *occurence != Occurence::ANY && signature.nonpositional_count.get(norm_name).is_none() {
735            errors.push(ValidationError {
736                severity: Severity::Warning,
737                pos_begin: None,
738                pos_end: None,
739                message: format!("String command '{{{}}}' is unexpected.", norm_name),
740                suggestion: Some(String::from("Remove this command.")),
741            });
742        }
743    }
744
745    errors
746}
747
748fn normalize_string(dialect: &Dialect, parsed: &mut ParsedString) {
749    let mut parameters = HashMap::new();
750
751    let mut pos = 0;
752    for fragment in &mut parsed.fragments {
753        match &mut fragment.content {
754            FragmentContent::Command(cmd) => {
755                if let Some(info) = COMMANDS
756                    .into_iter()
757                    .find(|ci| ci.name == cmd.name && ci.dialects.contains(&dialect))
758                {
759                    if let Some(norm_name) = info.norm_name {
760                        // normalize name
761                        cmd.name = String::from(norm_name);
762                    }
763                    if !info.parameters.is_empty() {
764                        if let Some(index) = cmd.index {
765                            pos = index;
766                        } else {
767                            // add missing indices
768                            cmd.index = Some(pos);
769                        }
770                        parameters.insert(pos, info);
771                        pos += 1;
772                    }
773                }
774            }
775            FragmentContent::Choice(cmd) => {
776                match cmd.name.as_str() {
777                    "P" => {
778                        if cmd.indexref.is_none() && pos > 0 {
779                            // add missing indices
780                            cmd.indexref = Some(pos - 1);
781                        }
782                    }
783                    "G" => {
784                        if cmd.indexref.is_none() {
785                            // add missing indices
786                            cmd.indexref = Some(pos);
787                        }
788                    }
789                    _ => panic!(),
790                };
791            }
792            _ => (),
793        }
794    }
795
796    for fragment in &mut parsed.fragments {
797        if let FragmentContent::Choice(cmd) = &mut fragment.content {
798            if let Some(ref_info) = cmd.indexref.and_then(|pos| parameters.get(&pos)) {
799                if cmd.indexsubref == ref_info.def_plural_subindex.or(Some(0)) {
800                    // remove subindex, if default
801                    cmd.indexsubref = None;
802                }
803            }
804        }
805    }
806}
807
808#[cfg(test)]
809mod tests {
810    use super::*;
811
812    #[test]
813    fn test_sanitize() {
814        let mut s1 = String::from("");
815        let mut s2 = String::from(" a b c ");
816        let mut s3 = String::from("\0a\tb\rc\r\n");
817        let mut s4 = String::from("abc\u{b3}");
818        remove_ascii_ctrl(&mut s1);
819        remove_ascii_ctrl(&mut s2);
820        remove_ascii_ctrl(&mut s3);
821        remove_ascii_ctrl(&mut s4);
822        assert_eq!(s1, String::from(""));
823        assert_eq!(s2, String::from(" a b c "));
824        assert_eq!(s3, String::from(" a b c  "));
825        assert_eq!(s4, String::from("abc\u{b3}"));
826        remove_trailing_blanks(&mut s1);
827        remove_trailing_blanks(&mut s2);
828        remove_trailing_blanks(&mut s3);
829        remove_trailing_blanks(&mut s4);
830        assert_eq!(s1, String::from(""));
831        assert_eq!(s2, String::from(" a b c"));
832        assert_eq!(s3, String::from(" a b c"));
833        assert_eq!(s4, String::from("abc\u{b3}"));
834    }
835
836    #[test]
837    fn test_signature_empty() {
838        let parsed = ParsedString::parse("").unwrap();
839        let sig = get_signature(&Dialect::OPENTTD, &parsed).unwrap();
840        assert!(sig.parameters.is_empty());
841        assert!(sig.nonpositional_count.is_empty());
842    }
843
844    #[test]
845    fn test_signature_pos() {
846        let parsed = ParsedString::parse("{P a b}{RED}{NUM}{NBSP}{MONO_FONT}{5:STRING.foo}{RED}{2:STRING3.bar}{RAW_STRING}{3:RAW_STRING}{G c d}").unwrap();
847        let sig = get_signature(&Dialect::OPENTTD, &parsed).unwrap();
848        assert_eq!(sig.parameters.len(), 4);
849        assert_eq!(sig.parameters.get(&0).unwrap().0.name, "NUM");
850        assert_eq!(sig.parameters.get(&0).unwrap().1, 1);
851        assert_eq!(sig.parameters.get(&5).unwrap().0.name, "STRING");
852        assert_eq!(sig.parameters.get(&5).unwrap().1, 1);
853        assert_eq!(sig.parameters.get(&2).unwrap().0.name, "STRING3");
854        assert_eq!(sig.parameters.get(&2).unwrap().1, 1);
855        assert_eq!(sig.parameters.get(&3).unwrap().0.name, "RAW_STRING");
856        assert_eq!(sig.parameters.get(&3).unwrap().1, 2);
857        assert_eq!(sig.nonpositional_count.len(), 3);
858        assert_eq!(
859            sig.nonpositional_count.get("RED"),
860            Some(&(Occurence::NONZERO, 2))
861        );
862        assert_eq!(
863            sig.nonpositional_count.get("MONO_FONT"),
864            Some(&(Occurence::EXACT, 1))
865        );
866        assert_eq!(
867            sig.nonpositional_count.get("NBSP"),
868            Some(&(Occurence::ANY, 1))
869        );
870    }
871
872    #[test]
873    fn test_signature_dialect() {
874        let parsed = ParsedString::parse("{RAW_STRING}").unwrap();
875
876        let sig = get_signature(&Dialect::OPENTTD, &parsed).unwrap();
877        assert_eq!(sig.parameters.len(), 1);
878        assert_eq!(sig.parameters.get(&0).unwrap().0.name, "RAW_STRING");
879        assert_eq!(sig.parameters.get(&0).unwrap().1, 1);
880        assert_eq!(sig.nonpositional_count.len(), 0);
881
882        let err = get_signature(&Dialect::NEWGRF, &parsed).err().unwrap();
883        assert_eq!(err.len(), 1);
884        assert_eq!(
885            err[0],
886            ValidationError {
887                severity: Severity::Error,
888                pos_begin: Some(0),
889                pos_end: Some(12),
890                message: String::from("Unknown string command '{RAW_STRING}'."),
891                suggestion: None,
892            }
893        );
894    }
895
896    #[test]
897    fn test_signature_unknown() {
898        let parsed = ParsedString::parse("{FOOBAR}").unwrap();
899        let err = get_signature(&Dialect::OPENTTD, &parsed).err().unwrap();
900        assert_eq!(err.len(), 1);
901        assert_eq!(
902            err[0],
903            ValidationError {
904                severity: Severity::Error,
905                pos_begin: Some(0),
906                pos_end: Some(8),
907                message: String::from("Unknown string command '{FOOBAR}'."),
908                suggestion: None,
909            }
910        );
911    }
912
913    #[test]
914    fn test_signature_nonpos() {
915        let parsed = ParsedString::parse("{1:RED}").unwrap();
916        let err = get_signature(&Dialect::OPENTTD, &parsed).err().unwrap();
917        assert_eq!(err.len(), 1);
918        assert_eq!(
919            err[0],
920            ValidationError {
921                severity: Severity::Error,
922                pos_begin: Some(0),
923                pos_end: Some(7),
924                message: String::from("Command '{RED}' cannot have a position reference."),
925                suggestion: Some(String::from("Remove '1:'.")),
926            }
927        );
928    }
929
930    #[test]
931    fn test_validate_empty() {
932        let config = LanguageConfig {
933            dialect: Dialect::OPENTTD,
934            cases: vec![],
935            genders: vec![],
936            plural_count: 0,
937        };
938        let base = ParsedString::parse("").unwrap();
939
940        let val_base = validate_string(&config, &base, None);
941        assert_eq!(val_base.len(), 0);
942
943        let val_trans = validate_string(&config, &base, Some(&base));
944        assert_eq!(val_trans.len(), 0);
945    }
946
947    #[test]
948    fn test_validate_invalid() {
949        let config = LanguageConfig {
950            dialect: Dialect::OPENTTD,
951            cases: vec![],
952            genders: vec![],
953            plural_count: 0,
954        };
955        let base = ParsedString::parse("{FOOBAR}").unwrap();
956
957        let val_base = validate_string(&config, &base, None);
958        assert_eq!(val_base.len(), 1);
959        assert_eq!(
960            val_base[0],
961            ValidationError {
962                severity: Severity::Error,
963                pos_begin: Some(0),
964                pos_end: Some(8),
965                message: String::from("Unknown string command '{FOOBAR}'."),
966                suggestion: None,
967            }
968        );
969
970        let val_trans = validate_string(&config, &base, Some(&base));
971        assert_eq!(val_trans.len(), 1);
972        assert_eq!(
973            val_trans[0],
974            ValidationError {
975                severity: Severity::Error,
976                pos_begin: None,
977                pos_end: None,
978                message: String::from("Base language text is invalid."),
979                suggestion: Some(String::from("This is a bug; wait until it is fixed.")),
980            }
981        );
982    }
983
984    #[test]
985    fn test_validate_positional() {
986        let config = LanguageConfig {
987            dialect: Dialect::OPENTTD,
988            cases: vec![],
989            genders: vec![],
990            plural_count: 0,
991        };
992        let base = ParsedString::parse("{NUM}").unwrap();
993        let val_base = validate_string(&config, &base, None);
994        assert_eq!(val_base.len(), 0);
995
996        {
997            let trans = ParsedString::parse("{0:NUM}").unwrap();
998            let val_trans = validate_string(&config, &trans, Some(&base));
999            assert_eq!(val_trans.len(), 0);
1000        }
1001        {
1002            let trans = ParsedString::parse("{FOOBAR}{NUM}").unwrap();
1003            let val_trans = validate_string(&config, &trans, Some(&base));
1004            assert_eq!(val_trans.len(), 1);
1005            assert_eq!(
1006                val_trans[0],
1007                ValidationError {
1008                    severity: Severity::Error,
1009                    pos_begin: Some(0),
1010                    pos_end: Some(8),
1011                    message: String::from("Unknown string command '{FOOBAR}'."),
1012                    suggestion: None,
1013                }
1014            );
1015        }
1016        {
1017            let trans = ParsedString::parse("{1:NUM}").unwrap();
1018            let val_trans = validate_string(&config, &trans, Some(&base));
1019            assert_eq!(val_trans.len(), 2);
1020            assert_eq!(
1021                val_trans[0],
1022                ValidationError {
1023                    severity: Severity::Error,
1024                    pos_begin: Some(0),
1025                    pos_end: Some(7),
1026                    message: String::from("There is no parameter in position 1, found '{NUM}'."),
1027                    suggestion: None,
1028                }
1029            );
1030            assert_eq!(
1031                val_trans[1],
1032                ValidationError {
1033                    severity: Severity::Error,
1034                    pos_begin: None,
1035                    pos_end: None,
1036                    message: String::from("String command '{0:NUM}' is missing."),
1037                    suggestion: None,
1038                }
1039            );
1040        }
1041        {
1042            let trans = ParsedString::parse("{COMMA}").unwrap();
1043            let val_trans = validate_string(&config, &trans, Some(&base));
1044            assert_eq!(val_trans.len(), 2);
1045            assert_eq!(
1046                val_trans[0],
1047                ValidationError {
1048                    severity: Severity::Error,
1049                    pos_begin: Some(0),
1050                    pos_end: Some(7),
1051                    message: String::from("Expected '{0:NUM}', found '{COMMA}'."),
1052                    suggestion: None,
1053                }
1054            );
1055            assert_eq!(
1056                val_trans[1],
1057                ValidationError {
1058                    severity: Severity::Error,
1059                    pos_begin: None,
1060                    pos_end: None,
1061                    message: String::from("String command '{0:NUM}' is missing."),
1062                    suggestion: None,
1063                }
1064            );
1065        }
1066        {
1067            let trans = ParsedString::parse("{0:NUM}{0:NUM}").unwrap();
1068            let val_trans = validate_string(&config, &trans, Some(&base));
1069            assert_eq!(val_trans.len(), 1);
1070            assert_eq!(
1071                val_trans[0],
1072                ValidationError {
1073                    severity: Severity::Warning,
1074                    pos_begin: None,
1075                    pos_end: None,
1076                    message: String::from(
1077                        "String command '{0:NUM}': expected 1 times, found 2 times."
1078                    ),
1079                    suggestion: None,
1080                }
1081            );
1082        }
1083    }
1084
1085    #[test]
1086    fn test_validate_front() {
1087        let config = LanguageConfig {
1088            dialect: Dialect::OPENTTD,
1089            cases: vec![],
1090            genders: vec![String::from("a"), String::from("b")],
1091            plural_count: 0,
1092        };
1093        let base = ParsedString::parse("{BIG_FONT}foo{NUM}").unwrap();
1094        let val_base = validate_string(&config, &base, None);
1095        assert_eq!(val_base.len(), 0);
1096
1097        {
1098            let trans = ParsedString::parse("{G=a}{BIG_FONT}bar{NUM}").unwrap();
1099            let val_trans = validate_string(&config, &trans, Some(&base));
1100            assert_eq!(val_trans.len(), 0);
1101        }
1102        {
1103            let trans = ParsedString::parse("{G=a}{G=a}{BIG_FONT}bar{NUM}").unwrap();
1104            let val_trans = validate_string(&config, &trans, Some(&base));
1105            assert_eq!(val_trans.len(), 1);
1106            assert_eq!(
1107                val_trans[0],
1108                ValidationError {
1109                    severity: Severity::Warning,
1110                    pos_begin: Some(5),
1111                    pos_end: Some(10),
1112                    message: String::from("Duplicate gender definition."),
1113                    suggestion: Some(String::from("Remove the second '{G=...}'.")),
1114                }
1115            );
1116        }
1117        {
1118            let trans = ParsedString::parse("{BIG_FONT}{G=a}bar{NUM}").unwrap();
1119            let val_trans = validate_string(&config, &trans, Some(&base));
1120            assert_eq!(val_trans.len(), 1);
1121            assert_eq!(
1122                val_trans[0],
1123                ValidationError {
1124                    severity: Severity::Warning,
1125                    pos_begin: Some(10),
1126                    pos_end: Some(15),
1127                    message: String::from("Gender definitions must be at the front."),
1128                    suggestion: Some(String::from(
1129                        "Move '{G=...}' to the front of the translation."
1130                    )),
1131                }
1132            );
1133        }
1134        {
1135            let trans = ParsedString::parse("foo{BIG_FONT}bar{NUM}").unwrap();
1136            let val_trans = validate_string(&config, &trans, Some(&base));
1137            assert_eq!(val_trans.len(), 0);
1138        }
1139        {
1140            let trans = ParsedString::parse("foo{G=a}bar{NUM}").unwrap();
1141            let val_trans = validate_string(&config, &trans, Some(&base));
1142            assert_eq!(val_trans.len(), 2);
1143            assert_eq!(
1144                val_trans[0],
1145                ValidationError {
1146                    severity: Severity::Warning,
1147                    pos_begin: Some(3),
1148                    pos_end: Some(8),
1149                    message: String::from("Gender definitions must be at the front."),
1150                    suggestion: Some(String::from(
1151                        "Move '{G=...}' to the front of the translation."
1152                    )),
1153                }
1154            );
1155            assert_eq!(
1156                val_trans[1],
1157                ValidationError {
1158                    severity: Severity::Warning,
1159                    pos_begin: None,
1160                    pos_end: None,
1161                    message: String::from("String command '{BIG_FONT}' is missing."),
1162                    suggestion: None,
1163                }
1164            );
1165        }
1166    }
1167
1168    #[test]
1169    fn test_validate_position_references() {
1170        let config = LanguageConfig {
1171            dialect: Dialect::OPENTTD,
1172            cases: vec![String::from("x"), String::from("y")],
1173            genders: vec![String::from("a"), String::from("b")],
1174            plural_count: 2,
1175        };
1176        let base = ParsedString::parse("{RED}{NUM}{STRING3}").unwrap();
1177        let val_base = validate_string(&config, &base, None);
1178        assert_eq!(val_base.len(), 0);
1179
1180        {
1181            let trans = ParsedString::parse("{RED}{1:STRING.x}{0:NUM}").unwrap();
1182            let val_trans = validate_string(&config, &trans, Some(&base));
1183            assert_eq!(val_trans.len(), 0);
1184        }
1185        {
1186            let trans = ParsedString::parse("{2:RED}{1:STRING.z}{0:NUM.x}").unwrap();
1187            let val_trans = validate_string(&config, &trans, Some(&base));
1188            assert_eq!(val_trans.len(), 3);
1189            assert_eq!(
1190                val_trans[0],
1191                ValidationError {
1192                    severity: Severity::Error,
1193                    pos_begin: Some(0),
1194                    pos_end: Some(7),
1195                    message: String::from("Command '{RED}' cannot have a position reference."),
1196                    suggestion: Some(String::from("Remove '2:'.")),
1197                }
1198            );
1199            assert_eq!(
1200                val_trans[1],
1201                ValidationError {
1202                    severity: Severity::Error,
1203                    pos_begin: Some(7),
1204                    pos_end: Some(19),
1205                    message: String::from("Unknown case 'z'."),
1206                    suggestion: Some(String::from("Known cases are: 'x', 'y'")),
1207                }
1208            );
1209            assert_eq!(
1210                val_trans[2],
1211                ValidationError {
1212                    severity: Severity::Error,
1213                    pos_begin: Some(19),
1214                    pos_end: Some(28),
1215                    message: String::from("No case selection allowed for '{NUM}'."),
1216                    suggestion: Some(String::from("Remove '.x'.")),
1217                }
1218            );
1219        }
1220        {
1221            let trans = ParsedString::parse("{RED}{NUM}{G i j}{P i j}{STRING.y}").unwrap();
1222            let val_trans = validate_string(&config, &trans, Some(&base));
1223            assert_eq!(val_trans.len(), 0);
1224        }
1225        {
1226            let trans = ParsedString::parse("{RED}{NUM}{G 0 i j}{P 1 i j}{STRING.y}").unwrap();
1227            let val_trans = validate_string(&config, &trans, Some(&base));
1228            assert_eq!(val_trans.len(), 2);
1229            assert_eq!(
1230                val_trans[0],
1231                ValidationError {
1232                    severity: Severity::Error,
1233                    pos_begin: Some(10),
1234                    pos_end: Some(19),
1235                    message: String::from(
1236                        "'{G}' references position '0:0', but '{0:NUM}' does not allow genders."
1237                    ),
1238                    suggestion: None,
1239                }
1240            );
1241            assert_eq!(
1242                val_trans[1],
1243                ValidationError {
1244                    severity: Severity::Error,
1245                    pos_begin: Some(19),
1246                    pos_end: Some(28),
1247                    message: String::from(
1248                        "'{P}' references position '1:0', but '{1:STRING}' does not allow plurals."
1249                    ),
1250                    suggestion: None,
1251                }
1252            );
1253        }
1254        {
1255            let trans = ParsedString::parse("{RED}{NUM}{G 1:1 i j}{P 1:3 i j}{STRING.y}").unwrap();
1256            let val_trans = validate_string(&config, &trans, Some(&base));
1257            assert_eq!(val_trans.len(), 0);
1258        }
1259        {
1260            let trans = ParsedString::parse("{RED}{NUM}{G 1:4 i j}{P 1:4 i j}{STRING.y}").unwrap();
1261            let val_trans = validate_string(&config, &trans, Some(&base));
1262            assert_eq!(val_trans.len(), 2);
1263            assert_eq!(
1264                val_trans[0],
1265                ValidationError {
1266                    severity: Severity::Error,
1267                    pos_begin: Some(10),
1268                    pos_end: Some(21),
1269                    message: String::from(
1270                        "'{G}' references position '1:4', but '{1:STRING}' only has 4 subindices."
1271                    ),
1272                    suggestion: None,
1273                }
1274            );
1275            assert_eq!(
1276                val_trans[1],
1277                ValidationError {
1278                    severity: Severity::Error,
1279                    pos_begin: Some(21),
1280                    pos_end: Some(32),
1281                    message: String::from(
1282                        "'{P}' references position '1:4', but '{1:STRING}' only has 4 subindices."
1283                    ),
1284                    suggestion: None,
1285                }
1286            );
1287        }
1288        {
1289            let trans = ParsedString::parse("{RED}{NUM}{G 2 i j}{P 2 i j}{STRING.y}").unwrap();
1290            let val_trans = validate_string(&config, &trans, Some(&base));
1291            assert_eq!(val_trans.len(), 2);
1292            assert_eq!(
1293                val_trans[0],
1294                ValidationError {
1295                    severity: Severity::Error,
1296                    pos_begin: Some(10),
1297                    pos_end: Some(19),
1298                    message: String::from("'{G}' references position '2', which has no parameter."),
1299                    suggestion: None,
1300                }
1301            );
1302            assert_eq!(
1303                val_trans[1],
1304                ValidationError {
1305                    severity: Severity::Error,
1306                    pos_begin: Some(19),
1307                    pos_end: Some(28),
1308                    message: String::from("'{P}' references position '2', which has no parameter."),
1309                    suggestion: None,
1310                }
1311            );
1312        }
1313        {
1314            let trans = ParsedString::parse("{RED}{P i j}{NUM}{STRING.y}{G i j}").unwrap();
1315            let val_trans = validate_string(&config, &trans, Some(&base));
1316            assert_eq!(val_trans.len(), 2);
1317            assert_eq!(
1318                val_trans[0],
1319                ValidationError {
1320                    severity: Severity::Error,
1321                    pos_begin: Some(5),
1322                    pos_end: Some(12),
1323                    message: String::from(
1324                        "'{P}' references position '-1', which has no parameter."
1325                    ),
1326                    suggestion: Some(String::from("Add a position reference.")),
1327                }
1328            );
1329            assert_eq!(
1330                val_trans[1],
1331                ValidationError {
1332                    severity: Severity::Error,
1333                    pos_begin: Some(27),
1334                    pos_end: Some(34),
1335                    message: String::from("'{G}' references position '2', which has no parameter."),
1336                    suggestion: Some(String::from("Add a position reference.")),
1337                }
1338            );
1339        }
1340    }
1341
1342    #[test]
1343    fn test_validate_nochoices() {
1344        let config = LanguageConfig {
1345            dialect: Dialect::OPENTTD,
1346            cases: vec![],
1347            genders: vec![],
1348            plural_count: 1,
1349        };
1350        let base = ParsedString::parse("{NUM}{STRING3}").unwrap();
1351        let val_base = validate_string(&config, &base, None);
1352        assert_eq!(val_base.len(), 0);
1353
1354        {
1355            let trans = ParsedString::parse("{G=a}{NUM}{P a}{G a}{STRING}").unwrap();
1356            let val_trans = validate_string(&config, &trans, Some(&base));
1357            assert_eq!(val_trans.len(), 3);
1358            assert_eq!(
1359                val_trans[0],
1360                ValidationError {
1361                    severity: Severity::Error,
1362                    pos_begin: Some(0),
1363                    pos_end: Some(5),
1364                    message: String::from("No gender definitions allowed."),
1365                    suggestion: Some(String::from("Remove '{G=...}'.")),
1366                }
1367            );
1368            assert_eq!(
1369                val_trans[1],
1370                ValidationError {
1371                    severity: Severity::Error,
1372                    pos_begin: Some(10),
1373                    pos_end: Some(15),
1374                    message: String::from("No plural choices allowed."),
1375                    suggestion: Some(String::from("Remove '{P ...}'.")),
1376                }
1377            );
1378            assert_eq!(
1379                val_trans[2],
1380                ValidationError {
1381                    severity: Severity::Error,
1382                    pos_begin: Some(15),
1383                    pos_end: Some(20),
1384                    message: String::from("No gender choices allowed."),
1385                    suggestion: Some(String::from("Remove '{G ...}'.")),
1386                }
1387            );
1388        }
1389    }
1390
1391    #[test]
1392    fn test_validate_gschoices() {
1393        let config = LanguageConfig {
1394            dialect: Dialect::GAMESCRIPT,
1395            cases: vec![String::from("x"), String::from("y")],
1396            genders: vec![String::from("a"), String::from("b")],
1397            plural_count: 2,
1398        };
1399        let base = ParsedString::parse("{NUM}{STRING3}").unwrap();
1400        let val_base = validate_string(&config, &base, None);
1401        assert_eq!(val_base.len(), 0);
1402
1403        {
1404            let trans = ParsedString::parse("{G=a}{NUM}{P a b}{G a b}{STRING.x}").unwrap();
1405            let val_trans = validate_string(&config, &trans, Some(&base));
1406            assert_eq!(val_trans.len(), 3);
1407            assert_eq!(
1408                val_trans[0],
1409                ValidationError {
1410                    severity: Severity::Error,
1411                    pos_begin: Some(0),
1412                    pos_end: Some(5),
1413                    message: String::from("No gender definitions allowed."),
1414                    suggestion: Some(String::from("Remove '{G=...}'.")),
1415                }
1416            );
1417            assert_eq!(
1418                val_trans[1],
1419                ValidationError {
1420                    severity: Severity::Error,
1421                    pos_begin: Some(17),
1422                    pos_end: Some(24),
1423                    message: String::from("No gender choices allowed."),
1424                    suggestion: Some(String::from("Remove '{G ...}'.")),
1425                }
1426            );
1427            assert_eq!(
1428                val_trans[2],
1429                ValidationError {
1430                    severity: Severity::Error,
1431                    pos_begin: Some(24),
1432                    pos_end: Some(34),
1433                    message: String::from("No case selections allowed."),
1434                    suggestion: Some(String::from("Remove '.x'.")),
1435                }
1436            );
1437        }
1438    }
1439
1440    #[test]
1441    fn test_validate_choices() {
1442        let config = LanguageConfig {
1443            dialect: Dialect::OPENTTD,
1444            cases: vec![String::from("x"), String::from("y")],
1445            genders: vec![String::from("a"), String::from("b")],
1446            plural_count: 2,
1447        };
1448        let base = ParsedString::parse("{NUM}{STRING3}").unwrap();
1449        let val_base = validate_string(&config, &base, None);
1450        assert_eq!(val_base.len(), 0);
1451
1452        {
1453            let trans = ParsedString::parse("{G=a}{NUM}{P a b}{G a b}{STRING.x}").unwrap();
1454            let val_trans = validate_string(&config, &trans, Some(&base));
1455            assert_eq!(val_trans.len(), 0);
1456        }
1457        {
1458            let trans = ParsedString::parse("{G=c}{NUM}{P a b c}{G a b c}{STRING.z}").unwrap();
1459            let val_trans = validate_string(&config, &trans, Some(&base));
1460            assert_eq!(val_trans.len(), 4);
1461            assert_eq!(
1462                val_trans[0],
1463                ValidationError {
1464                    severity: Severity::Error,
1465                    pos_begin: Some(0),
1466                    pos_end: Some(5),
1467                    message: String::from("Unknown gender 'c'."),
1468                    suggestion: Some(String::from("Known genders are: 'a', 'b'")),
1469                }
1470            );
1471            assert_eq!(
1472                val_trans[1],
1473                ValidationError {
1474                    severity: Severity::Error,
1475                    pos_begin: Some(10),
1476                    pos_end: Some(19),
1477                    message: String::from("Expected 2 plural choices, found 3."),
1478                    suggestion: None,
1479                }
1480            );
1481            assert_eq!(
1482                val_trans[2],
1483                ValidationError {
1484                    severity: Severity::Error,
1485                    pos_begin: Some(19),
1486                    pos_end: Some(28),
1487                    message: String::from("Expected 2 gender choices, found 3."),
1488                    suggestion: None,
1489                }
1490            );
1491            assert_eq!(
1492                val_trans[3],
1493                ValidationError {
1494                    severity: Severity::Error,
1495                    pos_begin: Some(28),
1496                    pos_end: Some(38),
1497                    message: String::from("Unknown case 'z'."),
1498                    suggestion: Some(String::from("Known cases are: 'x', 'y'")),
1499                }
1500            );
1501        }
1502    }
1503
1504    #[test]
1505    fn test_validate_nonpositional() {
1506        let config = LanguageConfig {
1507            dialect: Dialect::OPENTTD,
1508            cases: vec![],
1509            genders: vec![],
1510            plural_count: 0,
1511        };
1512        let base = ParsedString::parse("{RED}{NBSP}{}{GREEN}{NBSP}{}{RED}{TRAIN}").unwrap();
1513        let val_base = validate_string(&config, &base, None);
1514        assert_eq!(val_base.len(), 0);
1515
1516        {
1517            let trans = ParsedString::parse("{RED}{}{GREEN}{}{RED}{TRAIN}").unwrap();
1518            let val_trans = validate_string(&config, &trans, Some(&base));
1519            assert_eq!(val_trans.len(), 0);
1520        }
1521        {
1522            let trans = ParsedString::parse("{RED}{}{GREEN}{NBSP}{RED}{NBSP}{GREEN}{}{RED}{TRAIN}")
1523                .unwrap();
1524            let val_trans = validate_string(&config, &trans, Some(&base));
1525            assert_eq!(val_trans.len(), 0);
1526        }
1527        {
1528            let trans =
1529                ParsedString::parse("{RED}{}{RED}{TRAIN}{BLUE}{TRAIN}{RIGHT_ARROW}{SHIP}").unwrap();
1530            let val_trans = validate_string(&config, &trans, Some(&base));
1531            assert_eq!(val_trans.len(), 4);
1532            assert_eq!(
1533                val_trans[0],
1534                ValidationError {
1535                    severity: Severity::Warning,
1536                    pos_begin: None,
1537                    pos_end: None,
1538                    message: String::from("String command '{GREEN}' is missing."),
1539                    suggestion: None,
1540                }
1541            );
1542            assert_eq!(
1543                val_trans[1],
1544                ValidationError {
1545                    severity: Severity::Warning,
1546                    pos_begin: None,
1547                    pos_end: None,
1548                    message: String::from(
1549                        "String command '{TRAIN}': expected 1 times, found 2 times."
1550                    ),
1551                    suggestion: None,
1552                }
1553            );
1554            assert_eq!(
1555                val_trans[2],
1556                ValidationError {
1557                    severity: Severity::Warning,
1558                    pos_begin: None,
1559                    pos_end: None,
1560                    message: String::from("String command '{BLUE}' is unexpected."),
1561                    suggestion: Some(String::from("Remove this command.")),
1562                }
1563            );
1564            assert_eq!(
1565                val_trans[3],
1566                ValidationError {
1567                    severity: Severity::Warning,
1568                    pos_begin: None,
1569                    pos_end: None,
1570                    message: String::from("String command '{SHIP}' is unexpected."),
1571                    suggestion: Some(String::from("Remove this command.")),
1572                }
1573            );
1574        }
1575    }
1576
1577    #[test]
1578    fn test_normalize_cmd() {
1579        let mut parsed =
1580            ParsedString::parse("{RED}{NBSP}{2:RAW_STRING}{0:STRING5}{COMMA}").unwrap();
1581        normalize_string(&Dialect::OPENTTD, &mut parsed);
1582        let result = parsed.compile();
1583        assert_eq!(result, "{RED}{NBSP}{2:STRING}{0:STRING}{1:COMMA}");
1584    }
1585
1586    #[test]
1587    fn test_normalize_ref() {
1588        let mut parsed = ParsedString::parse("{RED}{NBSP}{P a b}{2:STRING}{P 1 a b}{G 0:1 a b}{0:STRING}{G 0 a b}{P 0:1 a b}{COMMA}{P a b}{G a b}").unwrap();
1589        normalize_string(&Dialect::OPENTTD, &mut parsed);
1590        let result = parsed.compile();
1591        assert_eq!(result, "{RED}{NBSP}{P a b}{2:STRING}{P 1 a b}{G 0:1 a b}{0:STRING}{G 0 a b}{P 0:1 a b}{1:COMMA}{P 1 a b}{G 2 a b}");
1592    }
1593
1594    #[test]
1595    fn test_normalize_subref() {
1596        let mut parsed = ParsedString::parse(
1597            "{NUM}{P 0:0 a b}{G 1:0 a b}{G 1:1 a b}{STRING}{P 1:2 a b}{CARGO_LONG}{P 2:1 a b}",
1598        )
1599        .unwrap();
1600        normalize_string(&Dialect::OPENTTD, &mut parsed);
1601        let result = parsed.compile();
1602        assert_eq!(
1603            result,
1604            "{0:NUM}{P 0 a b}{G 1 a b}{G 1:1 a b}{1:STRING}{P 1:2 a b}{2:CARGO_LONG}{P 2 a b}"
1605        );
1606    }
1607}