diff --git a/Cargo.lock b/Cargo.lock index 1571f36..dceaf75 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,15 @@ # It is not intended for manual editing. version = 3 +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + [[package]] name = "anstream" version = "0.6.13" @@ -139,12 +148,19 @@ version = "0.4.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c" +[[package]] +name = "memchr" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c8640c5d730cb13ebd907d8d04b52f55ac9a2eec55b440c8892f40d56c76c1d" + [[package]] name = "nile-library" version = "0.0.0-git" dependencies = [ "clap", "console_error_panic_hook", + "regex", "serde", "serde-wasm-bindgen", "wasm-bindgen", @@ -174,6 +190,35 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "regex" +version = "1.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c117dbdfde9c8308975b6a18d71f3f385c89461f7b3fb054288ecf2a2058ba4c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86b83b8b9847f9bf95ef68afb0b8e6cdb80f498442f5179a29fad448fcc1eaea" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adad44e29e4c806119491a7f06f03de4d1af22c3a680dd47f1e6e179439d1f56" + [[package]] name = "serde" version = "1.0.197" diff --git a/Cargo.toml b/Cargo.toml index 297bcfc..9052511 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ crate-type = ["cdylib", "rlib"] [dependencies] clap = { version = "4.5", features = ["derive" ]} console_error_panic_hook = "0.1" +regex = "1.10.4" serde = { version = "1.0", features = ["derive"] } serde-wasm-bindgen = "0.4" wasm-bindgen = "0.2" diff --git a/README.md b/README.md index 6004f47..18f6dee 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # nile-library - Library supporting nile -This repository contains the libirary that supports OpenTTD's translation tool `nile`. +This repository contains the library that supports OpenTTD's translation tool `nile`. This library for example validates if a translation is valid for a given base-string, and converts base-strings into a translatable form. @@ -12,11 +12,16 @@ Have Rust [installed](https://www.rust-lang.org/tools/install). For easy local development: -```bash -cargo run -- -``` +* Validate base string: + ```bash + cargo run -- + ``` +* Validate translation string: + ```bash + cargo run -- + ``` -It will output whether the translation is valid, and if not, what was wrong with it. +It will output the normalized string form, and whether the string is valid; and if not, what was wrong with it. ## WASM integration @@ -26,3 +31,78 @@ For this [wasm-pack](https://rustwasm.github.io/wasm-pack/) is used. ```bash wasm-pack build --release ``` + +## API usage + +### Step 1: Validate and normalize the base string + +**API method:** +```rust +fn validate_base(config: LanguageConfig, base: String) -> ValidationResult +``` + +**Input:** +* `config.dialect`: One of `openttd`, `newgrf`, `game-script`. +* `config.cases`: Empty for base language. +* `config.genders`: Empty for base language. +* `config.plural_count`: `2` for base language. +* `base`: Base string to validate + +**Output:** +* `errors`: List of errors. If this is not empty, the string should not be offered to translators. +* `normalized`: The normalized text to display to translators. + * In the normalized text, string commands like `RAW_STRING`, `STRING5`, ... are replaced with `STRING`. + * Translators can copy the normalized text as template for their translation. + +**Example:** +```console +>>> cargo run "{BLACK}Age: {LTBLUE}{STRING2}{BLACK} Running Cost: {LTBLUE}{CURRENCY}/year" +ERROR at position 61 to 71: Unknown string command '{CURRENCY}'. + +>>> cargo run "{BLACK}Age: {LTBLUE}{STRING2}{BLACK} Running Cost: {LTBLUE}{CURRENCY_LONG}/year" +NORMALIZED:{BLACK}Age: {LTBLUE}{0:STRING}{BLACK} Running Cost: {LTBLUE}{1:CURRENCY_LONG}/year +``` + +### Step 2: Translators translates strings + +* Translators must provide a text for the default case. +* Other cases are optional. +* Game-scripts do not support cases. There is a method in `LanguageConfig` to test for this, but it is not exported yet. + +### Step 3: Validate and normalize the translation string + +**API method:** +```rust +fn validate_translation(config: LanguageConfig, base: String, case: String, translation: String) -> ValidationResult +``` + +**Input:** +* `config.dialect`: One of `openttd`, `newgrf`, `game-script`. +* `config.cases`: `case` from `nile-config`. +* `config.genders`: `gender` from `nile-config`. +* `config.plural_count`: Number of plural forms from `nile-config`. +* `base`: Base string the translation is for. +* `case`: Case for the translation. Use `"default"` for the default case. +* `translation`: The text entered by the translator. + +**Output:** +* `errors`: List of errors. + * `severity`: Severity of the error. + * `error`: The translation is broken, and must not be committed to OpenTTD. + * `warning`: The translation is okay to commit, but translators should fix it anyway. This is used for new validations, which Eints did not do. So there are potentially lots of existing translations in violation. + * `position`: Byte position in input string. `None`, if general message without location. + * `message`: Error message. + * `suggestion`: Some extended message with hints. +* `normalized`: The normalized text to committed. In the normalized text, trailing whitespace and other junk has been removed. + +**Example:** +```console +>>> cargo run "{BLACK}Age: {LTBLUE}{STRING2}{BLACK} Running Cost: {LTBLUE}{CURRENCY_LONG}/year" "{BLUE}Alter: {LTBLUE}{STRING}{BLACK} Betriebskosten: {LTBLUE}{0:CURRENCY_LONG}/Jahr" +ERROR at position 61 to 78: Duplicate parameter '{0:CURRENCY_LONG}'. +ERROR at position 61 to 78: Expected '{0:STRING2}', found '{CURRENCY_LONG}'. +ERROR: String command '{1:CURRENCY_LONG}' is missing. +WARNING: String command '{BLUE}' is unexpected. HINT: Remove this command. + +>>> cargo run "{BLACK}Age: {LTBLUE}{STRING2}{BLACK} Running Cost: {LTBLUE}{CURRENCY_LONG}/year" "{BLACK}Alter: {LTBLUE}{STRING}{BLACK} Betriebskosten: {LTBLUE}{CURRENCY_LONG}/Jahr" +NORMALIZED:{BLACK}Alter: {LTBLUE}{0:STRING}{BLACK} Betriebskosten: {LTBLUE}{1:CURRENCY_LONG}/Jahr +``` diff --git a/src/commands.rs b/src/commands.rs new file mode 100644 index 0000000..f235c2a --- /dev/null +++ b/src/commands.rs @@ -0,0 +1,1198 @@ +pub struct ParameterInfo { + pub allow_plural: bool, + pub allow_gender: bool, +} + +#[derive(Debug, PartialEq, Copy, Clone)] +pub enum Occurence { + ANY, //< Command can be added or removed in translation without restriction. + NONZERO, //< Command must appear in translation if and only if it is present in the base, but the amount may differ. + EXACT, //< Command must match exactly with base. +} + +#[derive(PartialEq, Copy, Clone)] +pub enum Dialect { + NEWGRF, + GAMESCRIPT, + OPENTTD, +} + +impl From<&str> for Dialect { + fn from(src: &str) -> Self { + match src { + "newgrf" => Dialect::NEWGRF, + "game-script" => Dialect::GAMESCRIPT, + "openttd" => Dialect::OPENTTD, + _ => panic!(), + } + } +} + +impl Into for Dialect { + fn into(self) -> String { + match self { + Dialect::NEWGRF => String::from("newgrf"), + Dialect::GAMESCRIPT => String::from("game-script"), + Dialect::OPENTTD => String::from("openttd"), + } + } +} + +pub struct CommandInfo<'a> { + pub name: &'a str, + pub norm_name: Option<&'a str>, + pub dialects: &'a [Dialect], + pub occurence: Occurence, + pub front_only: bool, + pub allow_case: bool, + pub def_plural_subindex: Option, + pub parameters: &'a [ParameterInfo], +} + +impl<'a> CommandInfo<'a> { + pub fn get_norm_name(&self) -> &'a str { + self.norm_name.unwrap_or(self.name) + } +} + +const P__: ParameterInfo = ParameterInfo { + allow_plural: false, + allow_gender: false, +}; +const PP_: ParameterInfo = ParameterInfo { + allow_plural: true, + allow_gender: false, +}; +const P_G: ParameterInfo = ParameterInfo { + allow_plural: false, + allow_gender: true, +}; +const PPG: ParameterInfo = ParameterInfo { + allow_plural: true, + allow_gender: true, +}; + +const DN__: &'static [Dialect] = &[Dialect::NEWGRF]; +const DNGO: &'static [Dialect] = &[Dialect::NEWGRF, Dialect::GAMESCRIPT, Dialect::OPENTTD]; +const D_GO: &'static [Dialect] = &[Dialect::GAMESCRIPT, Dialect::OPENTTD]; +const D__O: &'static [Dialect] = &[Dialect::OPENTTD]; + +pub const COMMANDS: &'static [CommandInfo] = &[ + // names for unicode characters, freely usable by translators + CommandInfo { + name: "NBSP", + norm_name: None, + dialects: DNGO, + occurence: Occurence::ANY, + front_only: false, + allow_case: false, + def_plural_subindex: None, + parameters: &[], + }, + CommandInfo { + name: "{", + norm_name: None, + dialects: DNGO, + occurence: Occurence::ANY, + front_only: false, + allow_case: false, + def_plural_subindex: None, + parameters: &[], + }, + // these are still missing from NML: D_GO + CommandInfo { + name: "LRM", + norm_name: None, + dialects: D_GO, + occurence: Occurence::ANY, + front_only: false, + allow_case: false, + def_plural_subindex: None, + parameters: &[], + }, + CommandInfo { + name: "RLM", + norm_name: None, + dialects: D_GO, + occurence: Occurence::ANY, + front_only: false, + allow_case: false, + def_plural_subindex: None, + parameters: &[], + }, + CommandInfo { + name: "LRE", + norm_name: None, + dialects: D_GO, + occurence: Occurence::ANY, + front_only: false, + allow_case: false, + def_plural_subindex: None, + parameters: &[], + }, + CommandInfo { + name: "RLE", + norm_name: None, + dialects: D_GO, + occurence: Occurence::ANY, + front_only: false, + allow_case: false, + def_plural_subindex: None, + parameters: &[], + }, + CommandInfo { + name: "LRO", + norm_name: None, + dialects: D_GO, + occurence: Occurence::ANY, + front_only: false, + allow_case: false, + def_plural_subindex: None, + parameters: &[], + }, + CommandInfo { + name: "RLO", + norm_name: None, + dialects: D_GO, + occurence: Occurence::ANY, + front_only: false, + allow_case: false, + def_plural_subindex: None, + parameters: &[], + }, + CommandInfo { + name: "PDF", + norm_name: None, + dialects: D_GO, + occurence: Occurence::ANY, + front_only: false, + allow_case: false, + def_plural_subindex: None, + parameters: &[], + }, + // special characters which are supposed to match between base and translations + CommandInfo { + name: "", + norm_name: None, + dialects: DNGO, + occurence: Occurence::EXACT, + front_only: false, + allow_case: false, + def_plural_subindex: None, + parameters: &[], + }, + CommandInfo { + name: "COPYRIGHT", + norm_name: None, + dialects: DNGO, + occurence: Occurence::EXACT, + front_only: false, + allow_case: false, + def_plural_subindex: None, + parameters: &[], + }, + CommandInfo { + name: "TRAIN", + norm_name: None, + dialects: DNGO, + occurence: Occurence::EXACT, + front_only: false, + allow_case: false, + def_plural_subindex: None, + parameters: &[], + }, + CommandInfo { + name: "LORRY", + norm_name: None, + dialects: DNGO, + occurence: Occurence::EXACT, + front_only: false, + allow_case: false, + def_plural_subindex: None, + parameters: &[], + }, + CommandInfo { + name: "BUS", + norm_name: None, + dialects: DNGO, + occurence: Occurence::EXACT, + front_only: false, + allow_case: false, + def_plural_subindex: None, + parameters: &[], + }, + CommandInfo { + name: "PLANE", + norm_name: None, + dialects: DNGO, + occurence: Occurence::EXACT, + front_only: false, + allow_case: false, + def_plural_subindex: None, + parameters: &[], + }, + CommandInfo { + name: "SHIP", + norm_name: None, + dialects: DNGO, + occurence: Occurence::EXACT, + front_only: false, + allow_case: false, + def_plural_subindex: None, + parameters: &[], + }, + // Some string parameters are only allowed in the OpenTTD project. + // While they technically also work in Game Scripts, disencourage the usage. + // This also includes the "sprite" characters. + CommandInfo { + name: "REV", + norm_name: None, + dialects: D__O, + occurence: Occurence::EXACT, + front_only: false, + allow_case: false, + def_plural_subindex: None, + parameters: &[], + }, + CommandInfo { + name: "UP_ARROW", + norm_name: None, + dialects: D__O, + occurence: Occurence::EXACT, + front_only: false, + allow_case: false, + def_plural_subindex: None, + parameters: &[], + }, + CommandInfo { + name: "SMALL_UP_ARROW", + norm_name: None, + dialects: D__O, + occurence: Occurence::EXACT, + front_only: false, + allow_case: false, + def_plural_subindex: None, + parameters: &[], + }, + CommandInfo { + name: "SMALL_DOWN_ARROW", + norm_name: None, + dialects: D__O, + occurence: Occurence::EXACT, + front_only: false, + allow_case: false, + def_plural_subindex: None, + parameters: &[], + }, + CommandInfo { + name: "DOWN_ARROW", + norm_name: None, + dialects: D__O, + occurence: Occurence::EXACT, + front_only: false, + allow_case: false, + def_plural_subindex: None, + parameters: &[], + }, + CommandInfo { + name: "CHECKMARK", + norm_name: None, + dialects: D__O, + occurence: Occurence::EXACT, + front_only: false, + allow_case: false, + def_plural_subindex: None, + parameters: &[], + }, + CommandInfo { + name: "CROSS", + norm_name: None, + dialects: D__O, + occurence: Occurence::EXACT, + front_only: false, + allow_case: false, + def_plural_subindex: None, + parameters: &[], + }, + // left/right arrows are Occurence::ANY due to LTR/RTL languages + CommandInfo { + name: "RIGHT_ARROW", + norm_name: None, + dialects: D__O, + occurence: Occurence::ANY, + front_only: false, + allow_case: false, + def_plural_subindex: None, + parameters: &[], + }, + CommandInfo { + name: "SMALL_LEFT_ARROW", + norm_name: None, + dialects: D__O, + occurence: Occurence::ANY, + front_only: false, + allow_case: false, + def_plural_subindex: None, + parameters: &[], + }, + CommandInfo { + name: "SMALL_RIGHT_ARROW", + norm_name: None, + dialects: D__O, + occurence: Occurence::ANY, + front_only: false, + allow_case: false, + def_plural_subindex: None, + parameters: &[], + }, + CommandInfo { + name: "STATION_FEATURES", + norm_name: None, + dialects: D__O, + occurence: Occurence::EXACT, + front_only: false, + allow_case: false, + def_plural_subindex: None, + parameters: &[P__], + }, + // font sizes must be at the front: OpenTTD does not support text with mixed line height + CommandInfo { + name: "NORMAL_FONT", + norm_name: None, + dialects: D_GO, + occurence: Occurence::EXACT, + front_only: true, + allow_case: false, + def_plural_subindex: None, + parameters: &[], + }, + CommandInfo { + name: "TINY_FONT", + norm_name: None, + dialects: DNGO, + occurence: Occurence::EXACT, + front_only: true, + allow_case: false, + def_plural_subindex: None, + parameters: &[], + }, + CommandInfo { + name: "BIG_FONT", + norm_name: None, + dialects: DNGO, + occurence: Occurence::EXACT, + front_only: true, + allow_case: false, + def_plural_subindex: None, + parameters: &[], + }, + CommandInfo { + name: "MONO_FONT", + norm_name: None, + dialects: D_GO, + occurence: Occurence::EXACT, + front_only: true, + allow_case: false, + def_plural_subindex: None, + parameters: &[], + }, + // colour codes can be needed multiple times, when reordering text in translations + CommandInfo { + name: "BLUE", + norm_name: None, + dialects: DNGO, + occurence: Occurence::NONZERO, + front_only: false, + allow_case: false, + def_plural_subindex: None, + parameters: &[], + }, + CommandInfo { + name: "SILVER", + norm_name: None, + dialects: DNGO, + occurence: Occurence::NONZERO, + front_only: false, + allow_case: false, + def_plural_subindex: None, + parameters: &[], + }, + CommandInfo { + name: "GOLD", + norm_name: None, + dialects: DNGO, + occurence: Occurence::NONZERO, + front_only: false, + allow_case: false, + def_plural_subindex: None, + parameters: &[], + }, + CommandInfo { + name: "RED", + norm_name: None, + dialects: DNGO, + occurence: Occurence::NONZERO, + front_only: false, + allow_case: false, + def_plural_subindex: None, + parameters: &[], + }, + CommandInfo { + name: "PURPLE", + norm_name: None, + dialects: DNGO, + occurence: Occurence::NONZERO, + front_only: false, + allow_case: false, + def_plural_subindex: None, + parameters: &[], + }, + CommandInfo { + name: "LTBROWN", + norm_name: None, + dialects: DNGO, + occurence: Occurence::NONZERO, + front_only: false, + allow_case: false, + def_plural_subindex: None, + parameters: &[], + }, + CommandInfo { + name: "ORANGE", + norm_name: None, + dialects: DNGO, + occurence: Occurence::NONZERO, + front_only: false, + allow_case: false, + def_plural_subindex: None, + parameters: &[], + }, + CommandInfo { + name: "GREEN", + norm_name: None, + dialects: DNGO, + occurence: Occurence::NONZERO, + front_only: false, + allow_case: false, + def_plural_subindex: None, + parameters: &[], + }, + CommandInfo { + name: "YELLOW", + norm_name: None, + dialects: DNGO, + occurence: Occurence::NONZERO, + front_only: false, + allow_case: false, + def_plural_subindex: None, + parameters: &[], + }, + CommandInfo { + name: "DKGREEN", + norm_name: None, + dialects: DNGO, + occurence: Occurence::NONZERO, + front_only: false, + allow_case: false, + def_plural_subindex: None, + parameters: &[], + }, + CommandInfo { + name: "CREAM", + norm_name: None, + dialects: DNGO, + occurence: Occurence::NONZERO, + front_only: false, + allow_case: false, + def_plural_subindex: None, + parameters: &[], + }, + CommandInfo { + name: "BROWN", + norm_name: None, + dialects: DNGO, + occurence: Occurence::NONZERO, + front_only: false, + allow_case: false, + def_plural_subindex: None, + parameters: &[], + }, + CommandInfo { + name: "WHITE", + norm_name: None, + dialects: DNGO, + occurence: Occurence::NONZERO, + front_only: false, + allow_case: false, + def_plural_subindex: None, + parameters: &[], + }, + CommandInfo { + name: "LTBLUE", + norm_name: None, + dialects: DNGO, + occurence: Occurence::NONZERO, + front_only: false, + allow_case: false, + def_plural_subindex: None, + parameters: &[], + }, + CommandInfo { + name: "GRAY", + norm_name: None, + dialects: DNGO, + occurence: Occurence::NONZERO, + front_only: false, + allow_case: false, + def_plural_subindex: None, + parameters: &[], + }, + CommandInfo { + name: "DKBLUE", + norm_name: None, + dialects: DNGO, + occurence: Occurence::NONZERO, + front_only: false, + allow_case: false, + def_plural_subindex: None, + parameters: &[], + }, + CommandInfo { + name: "BLACK", + norm_name: None, + dialects: DNGO, + occurence: Occurence::NONZERO, + front_only: false, + allow_case: false, + def_plural_subindex: None, + parameters: &[], + }, + // TODO check that push and pop are balanced + CommandInfo { + name: "PUSH_COLOUR", + norm_name: None, + dialects: DNGO, + occurence: Occurence::ANY, + front_only: false, + allow_case: false, + def_plural_subindex: None, + parameters: &[], + }, + CommandInfo { + name: "POP_COLOUR", + norm_name: None, + dialects: DNGO, + occurence: Occurence::ANY, + front_only: false, + allow_case: false, + def_plural_subindex: None, + parameters: &[], + }, + CommandInfo { + name: "COLOUR", + norm_name: None, + dialects: D_GO, + occurence: Occurence::EXACT, + front_only: false, + allow_case: false, + def_plural_subindex: None, + parameters: &[P__], + }, + CommandInfo { + name: "POP_WORD", + norm_name: None, + dialects: DN__, + occurence: Occurence::EXACT, + front_only: false, + allow_case: false, + def_plural_subindex: None, + parameters: &[P__], + }, + // substrings + CommandInfo { + name: "STRING", + norm_name: None, + dialects: DNGO, + occurence: Occurence::EXACT, + front_only: false, + allow_case: true, + def_plural_subindex: None, + parameters: &[P_G], + }, + CommandInfo { + name: "RAW_STRING", + norm_name: Some("STRING"), + dialects: D_GO, + occurence: Occurence::EXACT, + front_only: false, + allow_case: false, + def_plural_subindex: None, + parameters: &[P_G], + }, + CommandInfo { + name: "STRING1", + norm_name: Some("STRING"), + dialects: D_GO, + occurence: Occurence::EXACT, + front_only: false, + allow_case: true, + def_plural_subindex: None, + parameters: &[P_G, PPG], + }, + CommandInfo { + name: "STRING2", + norm_name: Some("STRING"), + dialects: D_GO, + occurence: Occurence::EXACT, + front_only: false, + allow_case: true, + def_plural_subindex: None, + parameters: &[P_G, PPG, PPG], + }, + CommandInfo { + name: "STRING3", + norm_name: Some("STRING"), + dialects: D_GO, + occurence: Occurence::EXACT, + front_only: false, + allow_case: true, + def_plural_subindex: None, + parameters: &[P_G, PPG, PPG, PPG], + }, + CommandInfo { + name: "STRING4", + norm_name: Some("STRING"), + dialects: D_GO, + occurence: Occurence::EXACT, + front_only: false, + allow_case: true, + def_plural_subindex: None, + parameters: &[P_G, PPG, PPG, PPG, PPG], + }, + CommandInfo { + name: "STRING5", + norm_name: Some("STRING"), + dialects: D_GO, + occurence: Occurence::EXACT, + front_only: false, + allow_case: true, + def_plural_subindex: None, + parameters: &[P_G, PPG, PPG, PPG, PPG, PPG], + }, + CommandInfo { + name: "STRING6", + norm_name: Some("STRING"), + dialects: D_GO, + occurence: Occurence::EXACT, + front_only: false, + allow_case: true, + def_plural_subindex: None, + parameters: &[P_G, PPG, PPG, PPG, PPG, PPG, PPG], + }, + CommandInfo { + name: "STRING7", + norm_name: Some("STRING"), + dialects: D_GO, + occurence: Occurence::EXACT, + front_only: false, + allow_case: true, + def_plural_subindex: None, + parameters: &[P_G, PPG, PPG, PPG, PPG, PPG, PPG, PPG], + }, + // simple numbers + CommandInfo { + name: "COMMA", + norm_name: None, + dialects: DNGO, + occurence: Occurence::EXACT, + front_only: false, + allow_case: false, + def_plural_subindex: Some(0), + parameters: &[PP_], + }, + CommandInfo { + name: "SIGNED_WORD", + norm_name: None, + dialects: DN__, + occurence: Occurence::EXACT, + front_only: false, + allow_case: false, + def_plural_subindex: Some(0), + parameters: &[PP_], + }, + CommandInfo { + name: "UNSIGNED_WORD", + norm_name: None, + dialects: DN__, + occurence: Occurence::EXACT, + front_only: false, + allow_case: false, + def_plural_subindex: Some(0), + parameters: &[PP_], + }, + CommandInfo { + name: "HEX", + norm_name: None, + dialects: DNGO, + occurence: Occurence::EXACT, + front_only: false, + allow_case: false, + def_plural_subindex: Some(0), + parameters: &[PP_], + }, + CommandInfo { + name: "NUM", + norm_name: None, + dialects: D_GO, + occurence: Occurence::EXACT, + front_only: false, + allow_case: false, + def_plural_subindex: Some(0), + parameters: &[PP_], + }, + // formatted numbers + CommandInfo { + name: "ZEROFILL_NUM", + norm_name: None, + dialects: D_GO, + occurence: Occurence::EXACT, + front_only: false, + allow_case: false, + def_plural_subindex: Some(0), + parameters: &[PP_, P__], + }, + CommandInfo { + name: "DECIMAL", + norm_name: None, + dialects: D_GO, + occurence: Occurence::EXACT, + front_only: false, + allow_case: false, + def_plural_subindex: Some(0), + parameters: &[PP_, P__], + }, + // numbers with unit + CommandInfo { + name: "BYTES", + norm_name: None, + dialects: D_GO, + occurence: Occurence::EXACT, + front_only: false, + allow_case: false, + def_plural_subindex: Some(0), + parameters: &[PP_], + }, + CommandInfo { + name: "HEIGHT", + norm_name: None, + dialects: D_GO, + occurence: Occurence::EXACT, + front_only: false, + allow_case: false, + def_plural_subindex: Some(0), + parameters: &[PP_], + }, + CommandInfo { + name: "CURRENCY", + norm_name: None, + dialects: DN__, + occurence: Occurence::EXACT, + front_only: false, + allow_case: false, + def_plural_subindex: Some(0), + parameters: &[PP_], + }, + CommandInfo { + name: "CURRENCY_LONG", + norm_name: None, + dialects: D_GO, + occurence: Occurence::EXACT, + front_only: false, + allow_case: false, + def_plural_subindex: Some(0), + parameters: &[PP_], + }, + CommandInfo { + name: "CURRENCY_SHORT", + norm_name: None, + dialects: D_GO, + occurence: Occurence::EXACT, + front_only: false, + allow_case: false, + def_plural_subindex: Some(0), + parameters: &[PP_], + }, + CommandInfo { + name: "VELOCITY", + norm_name: None, + dialects: DNGO, + occurence: Occurence::EXACT, + front_only: false, + allow_case: false, + def_plural_subindex: Some(0), + parameters: &[PP_], + }, + CommandInfo { + name: "VOLUME", + norm_name: None, + dialects: DN__, + occurence: Occurence::EXACT, + front_only: false, + allow_case: false, + def_plural_subindex: Some(0), + parameters: &[PP_], + }, + CommandInfo { + name: "VOLUME_LONG", + norm_name: None, + dialects: D_GO, + occurence: Occurence::EXACT, + front_only: false, + allow_case: false, + def_plural_subindex: Some(0), + parameters: &[PP_], + }, + CommandInfo { + name: "VOLUME_SHORT", + norm_name: None, + dialects: DNGO, + occurence: Occurence::EXACT, + front_only: false, + allow_case: false, + def_plural_subindex: Some(0), + parameters: &[PP_], + }, + CommandInfo { + name: "FORCE", + norm_name: None, + dialects: D_GO, + occurence: Occurence::EXACT, + front_only: false, + allow_case: false, + def_plural_subindex: Some(0), + parameters: &[PP_], + }, + CommandInfo { + name: "POWER", + norm_name: None, + dialects: DNGO, + occurence: Occurence::EXACT, + front_only: false, + allow_case: false, + def_plural_subindex: Some(0), + parameters: &[PP_], + }, + CommandInfo { + name: "POWER_TO_WEIGHT", + norm_name: None, + dialects: D_GO, + occurence: Occurence::EXACT, + front_only: false, + allow_case: false, + def_plural_subindex: Some(0), + parameters: &[PP_], + }, + CommandInfo { + name: "WEIGHT", + norm_name: None, + dialects: DN__, + occurence: Occurence::EXACT, + front_only: false, + allow_case: false, + def_plural_subindex: Some(0), + parameters: &[PP_], + }, + CommandInfo { + name: "WEIGHT_LONG", + norm_name: None, + dialects: D_GO, + occurence: Occurence::EXACT, + front_only: false, + allow_case: false, + def_plural_subindex: Some(0), + parameters: &[PP_], + }, + CommandInfo { + name: "WEIGHT_SHORT", + norm_name: None, + dialects: DNGO, + occurence: Occurence::EXACT, + front_only: false, + allow_case: false, + def_plural_subindex: Some(0), + parameters: &[PP_], + }, + CommandInfo { + name: "UNITS_DAYS_OR_SECONDS", + norm_name: None, + dialects: D_GO, + occurence: Occurence::EXACT, + front_only: false, + allow_case: false, + def_plural_subindex: Some(0), + parameters: &[PPG], + }, + CommandInfo { + name: "UNITS_MONTHS_OR_MINUTES", + norm_name: None, + dialects: D_GO, + occurence: Occurence::EXACT, + front_only: false, + allow_case: false, + def_plural_subindex: Some(0), + parameters: &[PPG], + }, + CommandInfo { + name: "UNITS_YEARS_OR_PERIODS", + norm_name: None, + dialects: D_GO, + occurence: Occurence::EXACT, + front_only: false, + allow_case: false, + def_plural_subindex: Some(0), + parameters: &[PPG], + }, + CommandInfo { + name: "UNITS_YEARS_OR_MINUTES", + norm_name: None, + dialects: D_GO, + occurence: Occurence::EXACT, + front_only: false, + allow_case: false, + def_plural_subindex: Some(0), + parameters: &[PPG], + }, + // cargo amounts + CommandInfo { + name: "CARGO_LONG", + norm_name: None, + dialects: DNGO, + occurence: Occurence::EXACT, + front_only: false, + allow_case: false, + def_plural_subindex: Some(1), + parameters: &[P_G, PP_], + }, + CommandInfo { + name: "CARGO_SHORT", + norm_name: None, + dialects: DNGO, + occurence: Occurence::EXACT, + front_only: false, + allow_case: false, + def_plural_subindex: Some(1), + parameters: &[P_G, PP_], + }, + CommandInfo { + name: "CARGO_TINY", + norm_name: None, + dialects: DNGO, + occurence: Occurence::EXACT, + front_only: false, + allow_case: false, + def_plural_subindex: Some(1), + parameters: &[P__, PP_], + }, + // dates + CommandInfo { + name: "DATE1920_LONG", + norm_name: None, + dialects: DN__, + occurence: Occurence::EXACT, + front_only: false, + allow_case: true, + def_plural_subindex: None, + parameters: &[P__], + }, + CommandInfo { + name: "DATE1920_SHORT", + norm_name: None, + dialects: DN__, + occurence: Occurence::EXACT, + front_only: false, + allow_case: true, + def_plural_subindex: None, + parameters: &[P__], + }, + CommandInfo { + name: "DATE_LONG", + norm_name: None, + dialects: DNGO, + occurence: Occurence::EXACT, + front_only: false, + allow_case: true, + def_plural_subindex: None, + parameters: &[P__], + }, + CommandInfo { + name: "DATE_SHORT", + norm_name: None, + dialects: DNGO, + occurence: Occurence::EXACT, + front_only: false, + allow_case: true, + def_plural_subindex: None, + parameters: &[P__], + }, + CommandInfo { + name: "DATE_TINY", + norm_name: None, + dialects: D_GO, + occurence: Occurence::EXACT, + front_only: false, + allow_case: false, + def_plural_subindex: None, + parameters: &[P__], + }, + CommandInfo { + name: "DATE_ISO", + norm_name: None, + dialects: D_GO, + occurence: Occurence::EXACT, + front_only: false, + allow_case: false, + def_plural_subindex: None, + parameters: &[P__], + }, + // names + CommandInfo { + name: "CARGO_NAME", + norm_name: None, + dialects: DN__, + occurence: Occurence::EXACT, + front_only: false, + allow_case: true, + def_plural_subindex: None, + parameters: &[P_G], + }, + CommandInfo { + name: "CARGO_LIST", + norm_name: None, + dialects: D_GO, + occurence: Occurence::EXACT, + front_only: false, + allow_case: true, + def_plural_subindex: None, + parameters: &[P__], + }, + CommandInfo { + name: "INDUSTRY", + norm_name: None, + dialects: D_GO, + occurence: Occurence::EXACT, + front_only: false, + allow_case: true, + def_plural_subindex: None, + parameters: &[P_G], + }, + CommandInfo { + name: "WAYPOINT", + norm_name: None, + dialects: D_GO, + occurence: Occurence::EXACT, + front_only: false, + allow_case: false, + def_plural_subindex: None, + parameters: &[P_G], + }, + CommandInfo { + name: "STATION", + norm_name: None, + dialects: DNGO, + occurence: Occurence::EXACT, + front_only: false, + allow_case: false, + def_plural_subindex: None, + parameters: &[P_G], + }, + CommandInfo { + name: "DEPOT", + norm_name: None, + dialects: D_GO, + occurence: Occurence::EXACT, + front_only: false, + allow_case: false, + def_plural_subindex: None, + parameters: &[P_G, P__], + }, + CommandInfo { + name: "TOWN", + norm_name: None, + dialects: D_GO, + occurence: Occurence::EXACT, + front_only: false, + allow_case: false, + def_plural_subindex: None, + parameters: &[P_G], + }, + CommandInfo { + name: "GROUP", + norm_name: None, + dialects: D_GO, + occurence: Occurence::EXACT, + front_only: false, + allow_case: false, + def_plural_subindex: None, + parameters: &[P_G], + }, + CommandInfo { + name: "SIGN", + norm_name: None, + dialects: D_GO, + occurence: Occurence::EXACT, + front_only: false, + allow_case: false, + def_plural_subindex: None, + parameters: &[P_G], + }, + CommandInfo { + name: "ENGINE", + norm_name: None, + dialects: D_GO, + occurence: Occurence::EXACT, + front_only: false, + allow_case: false, + def_plural_subindex: None, + parameters: &[P_G], + }, + CommandInfo { + name: "VEHICLE", + norm_name: None, + dialects: D_GO, + occurence: Occurence::EXACT, + front_only: false, + allow_case: false, + def_plural_subindex: None, + parameters: &[P_G], + }, + CommandInfo { + name: "COMPANY", + norm_name: None, + dialects: D_GO, + occurence: Occurence::EXACT, + front_only: false, + allow_case: false, + def_plural_subindex: None, + parameters: &[P_G], + }, + CommandInfo { + name: "COMPANY_NUM", + norm_name: None, + dialects: D_GO, + occurence: Occurence::EXACT, + front_only: false, + allow_case: false, + def_plural_subindex: None, + parameters: &[P__], + }, + CommandInfo { + name: "PRESIDENT_NAME", + norm_name: None, + dialects: D_GO, + occurence: Occurence::EXACT, + front_only: false, + allow_case: false, + def_plural_subindex: None, + parameters: &[P_G], + }, +]; diff --git a/src/lib.rs b/src/lib.rs index fc9ea6e..c5c2610 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,18 +1,27 @@ use serde_wasm_bindgen; use wasm_bindgen::prelude::*; +mod commands; +mod parser; mod validate; #[wasm_bindgen] -pub fn validate(js_config: JsValue, base: String, case: String, translation: String) -> JsValue { +pub fn validate_base(js_config: JsValue, base: String) -> JsValue { let config: validate::LanguageConfig = serde_wasm_bindgen::from_value(js_config).unwrap(); - let response = validate::validate(config, base, case, translation); + let response = validate::validate_base(config, base); + serde_wasm_bindgen::to_value(&response).unwrap() +} - if let Some(error) = response { - serde_wasm_bindgen::to_value(&error).unwrap() - } else { - JsValue::NULL - } +#[wasm_bindgen] +pub fn validate_translation( + js_config: JsValue, + base: String, + case: String, + translation: String, +) -> JsValue { + let config: validate::LanguageConfig = serde_wasm_bindgen::from_value(js_config).unwrap(); + let response = validate::validate_translation(config, base, case, translation); + serde_wasm_bindgen::to_value(&response).unwrap() } #[wasm_bindgen] diff --git a/src/main.rs b/src/main.rs index 9aef004..6b65a9f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,35 +1,62 @@ use clap::Parser; +mod commands; +mod parser; mod validate; #[derive(Parser, Debug)] #[command(version, about, long_about = None)] struct Args { base: String, - case: String, - translation: String, + translation: Option, + case: Option, + #[clap(short, long, default_value_t = String::from("openttd"))] + dialect: String, #[clap(short, long)] cases: Vec, #[clap(short, long)] genders: Vec, - #[clap(short, long, default_value_t = 1)] - plural_count: u32, + #[clap(short, long, default_value_t = 2)] + plural_count: usize, } fn main() { let args = Args::parse(); let config = validate::LanguageConfig { + dialect: args.dialect, cases: args.cases, genders: args.genders, plural_count: args.plural_count, }; - let result = validate::validate(config, args.base, args.case, args.translation); + let result = match args.translation { + Some(translation) => validate::validate_translation( + config, + args.base, + args.case.unwrap_or(String::from("default")), + translation, + ), + None => validate::validate_base(config, args.base), + }; + + for err in &result.errors { + let sev = match err.severity { + validate::Severity::Error => "ERROR", + validate::Severity::Warning => "WARNING", + }; + let pos_begin = err + .pos_begin + .map_or(String::new(), |p| format!(" at position {}", p)); + let pos_end = err.pos_end.map_or(String::new(), |p| format!(" to {}", p)); + let hint = err + .suggestion + .as_ref() + .map_or(String::new(), |h| format!(" HINT: {}", h)); + println!("{}{}{}: {}{}", sev, pos_begin, pos_end, err.message, hint); + } - if let Some(error) = result { - println!("Validation failed: {}", error.message); - } else { - println!("Validation succeeded"); + if let Some(normalized) = result.normalized { + println!("NORMALIZED:{}", normalized); } } diff --git a/src/parser.rs b/src/parser.rs new file mode 100644 index 0000000..e39437a --- /dev/null +++ b/src/parser.rs @@ -0,0 +1,645 @@ +use regex::Regex; + +#[derive(Debug, PartialEq)] +pub struct StringCommand { + pub index: Option, + pub name: String, + pub case: Option, +} + +#[derive(Debug, PartialEq)] +pub struct GenderDefinition { + pub gender: String, +} + +#[derive(Debug, PartialEq)] +pub struct ChoiceList { + pub name: String, + pub indexref: Option, + pub indexsubref: Option, + pub choices: Vec, +} + +#[derive(Debug, PartialEq)] +pub enum FragmentContent { + Text(String), + Command(StringCommand), + Gender(GenderDefinition), + Choice(ChoiceList), +} + +#[derive(Debug, PartialEq)] +pub struct StringFragment { + pub pos_begin: usize, + pub pos_end: usize, + pub content: FragmentContent, +} + +#[derive(Debug, PartialEq)] +pub struct ParsedString { + pub fragments: Vec, +} + +#[derive(Debug, PartialEq)] +pub struct ParserError { + pub pos_begin: usize, + pub pos_end: Option, + pub message: String, +} + +impl StringCommand { + fn parse(string: &str) -> Option { + let pat_command = + Regex::new(r"^\{(?:(\d+):)?(|\{|[A-Z]+[A-Z0-9_]*)(?:\.(\w+))?\}$").unwrap(); + let caps = pat_command.captures(string)?; + Some(StringCommand { + index: caps.get(1).and_then(|v| v.as_str().parse().ok()), + name: String::from(&caps[2]), + case: caps.get(3).map(|v| String::from(v.as_str())), + }) + } + + fn compile(&self) -> String { + let mut result = String::from("{"); + if let Some(i) = self.index { + result.push_str(&format!("{}:", i)); + } + result.push_str(&self.name); + if let Some(case) = &self.case { + result.push_str(&format!(".{}", case)); + } + result.push_str("}"); + result + } +} + +impl GenderDefinition { + fn parse(string: &str) -> Option { + let pat_gender = Regex::new(r"^\{G\s*=\s*(\w+)\}$").unwrap(); + let caps = pat_gender.captures(string)?; + Some(GenderDefinition { + gender: String::from(&caps[1]), + }) + } + + fn compile(&self) -> String { + format!("{{G={}}}", self.gender) + } +} + +impl ChoiceList { + fn parse(string: &str) -> Option { + let pat_choice = + Regex::new(r"^\{([PG])(?:\s+(\d+)(?::(\d+))?)?(\s+[^\s0-9].*?)\s*\}$").unwrap(); + let pat_item = Regex::new(r##"^\s+(?:([^\s"]+)|"([^"]*)")"##).unwrap(); + let caps = pat_choice.captures(string)?; + let mut result = ChoiceList { + name: String::from(&caps[1]), + indexref: caps.get(2).and_then(|v| v.as_str().parse().ok()), + indexsubref: caps.get(3).and_then(|v| v.as_str().parse().ok()), + choices: Vec::new(), + }; + let mut rest = &caps[4]; + while !rest.is_empty() { + let m = pat_item.captures(rest)?; + result + .choices + .push(String::from(m.get(1).or(m.get(2)).unwrap().as_str())); + rest = &rest[m.get(0).unwrap().end()..]; + } + return Some(result); + } + + fn compile(&self) -> String { + let mut result = format!("{{{}", self.name); + if let Some(i) = self.indexref { + result.push_str(&format!(" {}", i)); + if let Some(s) = self.indexsubref { + result.push_str(&format!(":{}", s)) + } + } + for c in &self.choices { + if c.is_empty() || c.contains(|v| char::is_ascii_whitespace(&v)) { + result.push_str(&format!(r##" "{}""##, c)); + } else { + result.push_str(&format!(" {}", c)); + } + } + result.push_str("}"); + result + } +} + +impl FragmentContent { + fn parse(string: &str) -> Result { + if let Some(command) = StringCommand::parse(string) { + Ok(FragmentContent::Command(command)) + } else if let Some(gender) = GenderDefinition::parse(string) { + Ok(FragmentContent::Gender(gender)) + } else if let Some(choice) = ChoiceList::parse(string) { + Ok(FragmentContent::Choice(choice)) + } else { + Err(format!("Invalid string command: '{}'", string)) + } + } + + fn compile(&self) -> String { + match self { + Self::Text(s) => s.clone(), + Self::Command(command) => command.compile(), + Self::Gender(gender) => gender.compile(), + Self::Choice(choice) => choice.compile(), + } + } +} + +impl ParsedString { + pub fn parse(string: &str) -> Result { + let mut result = ParsedString { + fragments: Vec::new(), + }; + let mut rest: &str = string; + let mut pos_code: usize = 0; + while !rest.is_empty() { + if let Some(start) = rest.find('{') { + if start > 0 { + let text: &str; + (text, rest) = rest.split_at(start); + let len_code = text.chars().count(); + result.fragments.push(StringFragment { + pos_begin: pos_code, + pos_end: pos_code + len_code, + content: FragmentContent::Text(String::from(text)), + }); + pos_code += len_code; + } + if let Some(end) = rest.find('}') { + let text: &str; + (text, rest) = rest.split_at(end + 1); + let len_code = text.chars().count(); + match FragmentContent::parse(text) { + Ok(content) => result.fragments.push(StringFragment { + pos_begin: pos_code, + pos_end: pos_code + len_code, + content: content, + }), + Err(message) => { + return Err(ParserError { + pos_begin: pos_code, + pos_end: Some(pos_code + len_code), + message: message, + }); + } + }; + pos_code += len_code; + } else { + return Err(ParserError { + pos_begin: pos_code, + pos_end: None, + message: String::from("Unterminated string command, '}' expected."), + }); + } + } else { + let len_code = rest.chars().count(); + result.fragments.push(StringFragment { + pos_begin: pos_code, + pos_end: pos_code + len_code, + content: FragmentContent::Text(String::from(rest)), + }); + break; + } + } + Ok(result) + } + + pub fn compile(&self) -> String { + let mut result = String::new(); + for f in &self.fragments { + result.push_str(&f.content.compile()); + } + result + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_cmd_ok() { + assert_eq!( + FragmentContent::parse("{}"), + Ok(FragmentContent::Command(StringCommand { + index: None, + name: String::from(""), + case: None + })) + ); + assert_eq!( + FragmentContent::parse("{{}"), + Ok(FragmentContent::Command(StringCommand { + index: None, + name: String::from("{"), + case: None + })) + ); + assert_eq!( + FragmentContent::parse("{BIG_FONT}"), + Ok(FragmentContent::Command(StringCommand { + index: None, + name: String::from("BIG_FONT"), + case: None + })) + ); + assert_eq!( + FragmentContent::parse("{NUM}"), + Ok(FragmentContent::Command(StringCommand { + index: None, + name: String::from("NUM"), + case: None + })) + ); + assert_eq!( + FragmentContent::parse("{1:RED}"), + Ok(FragmentContent::Command(StringCommand { + index: Some(1), + name: String::from("RED"), + case: None + })) + ); + assert_eq!( + FragmentContent::parse("{STRING.gen}"), + Ok(FragmentContent::Command(StringCommand { + index: None, + name: String::from("STRING"), + case: Some(String::from("gen")) + })) + ); + assert_eq!( + FragmentContent::parse("{1:STRING.gen}"), + Ok(FragmentContent::Command(StringCommand { + index: Some(1), + name: String::from("STRING"), + case: Some(String::from("gen")) + })) + ); + assert_eq!( + FragmentContent::parse("{G=n}"), + Ok(FragmentContent::Gender(GenderDefinition { + gender: String::from("n") + })) + ); + assert_eq!( + FragmentContent::parse("{G = n}"), + Ok(FragmentContent::Gender(GenderDefinition { + gender: String::from("n") + })) + ); + assert_eq!( + FragmentContent::parse("{P a b}"), + Ok(FragmentContent::Choice(ChoiceList { + name: String::from("P"), + indexref: None, + indexsubref: None, + choices: vec![String::from("a"), String::from("b")] + })) + ); + assert_eq!( + FragmentContent::parse("{P\na\tb}"), + Ok(FragmentContent::Choice(ChoiceList { + name: String::from("P"), + indexref: None, + indexsubref: None, + choices: vec![String::from("a"), String::from("b")] + })) + ); + assert_eq!( + FragmentContent::parse(r##"{P "" b}"##), + Ok(FragmentContent::Choice(ChoiceList { + name: String::from("P"), + indexref: None, + indexsubref: None, + choices: vec![String::from(""), String::from("b")] + })) + ); + assert_eq!( + FragmentContent::parse(r##"{P "a b" "c"}"##), + Ok(FragmentContent::Choice(ChoiceList { + name: String::from("P"), + indexref: None, + indexsubref: None, + choices: vec![String::from("a b"), String::from("c")] + })) + ); + assert_eq!( + FragmentContent::parse("{P 1 a b}"), + Ok(FragmentContent::Choice(ChoiceList { + name: String::from("P"), + indexref: Some(1), + indexsubref: None, + choices: vec![String::from("a"), String::from("b")] + })) + ); + assert_eq!( + FragmentContent::parse("{P\t1\na\rb\n}"), + Ok(FragmentContent::Choice(ChoiceList { + name: String::from("P"), + indexref: Some(1), + indexsubref: None, + choices: vec![String::from("a"), String::from("b")] + })) + ); + assert_eq!( + FragmentContent::parse(r##"{P 1 "" b}"##), + Ok(FragmentContent::Choice(ChoiceList { + name: String::from("P"), + indexref: Some(1), + indexsubref: None, + choices: vec![String::from(""), String::from("b")] + })) + ); + assert_eq!( + FragmentContent::parse(r##"{P 1 "a b" "c"}"##), + Ok(FragmentContent::Choice(ChoiceList { + name: String::from("P"), + indexref: Some(1), + indexsubref: None, + choices: vec![String::from("a b"), String::from("c")] + })) + ); + assert_eq!( + FragmentContent::parse("{P 1:2 a b}"), + Ok(FragmentContent::Choice(ChoiceList { + name: String::from("P"), + indexref: Some(1), + indexsubref: Some(2), + choices: vec![String::from("a"), String::from("b")] + })) + ); + assert_eq!( + FragmentContent::parse(r##"{P 1:2 "" b}"##), + Ok(FragmentContent::Choice(ChoiceList { + name: String::from("P"), + indexref: Some(1), + indexsubref: Some(2), + choices: vec![String::from(""), String::from("b")] + })) + ); + assert_eq!( + FragmentContent::parse(r##"{P 1:2 "a b" "c"}"##), + Ok(FragmentContent::Choice(ChoiceList { + name: String::from("P"), + indexref: Some(1), + indexsubref: Some(2), + choices: vec![String::from("a b"), String::from("c")] + })) + ); + + assert_eq!( + FragmentContent::parse("{P a b c}"), + Ok(FragmentContent::Choice(ChoiceList { + name: String::from("P"), + indexref: None, + indexsubref: None, + choices: vec![String::from("a"), String::from("b"), String::from("c")] + })) + ); + assert_eq!( + FragmentContent::parse(r##"{P "" "" b}"##), + Ok(FragmentContent::Choice(ChoiceList { + name: String::from("P"), + indexref: None, + indexsubref: None, + choices: vec![String::from(""), String::from(""), String::from("b")] + })) + ); + assert_eq!( + FragmentContent::parse(r##"{P a ""}"##), + Ok(FragmentContent::Choice(ChoiceList { + name: String::from("P"), + indexref: None, + indexsubref: None, + choices: vec![String::from("a"), String::from("")] + })) + ); + assert_eq!( + FragmentContent::parse("{P 1 a b c}"), + Ok(FragmentContent::Choice(ChoiceList { + name: String::from("P"), + indexref: Some(1), + indexsubref: None, + choices: vec![String::from("a"), String::from("b"), String::from("c")] + })) + ); + assert_eq!( + FragmentContent::parse(r##"{P 1 "" "" b}"##), + Ok(FragmentContent::Choice(ChoiceList { + name: String::from("P"), + indexref: Some(1), + indexsubref: None, + choices: vec![String::from(""), String::from(""), String::from("b")] + })) + ); + assert_eq!( + FragmentContent::parse(r##"{P 1 a ""}"##), + Ok(FragmentContent::Choice(ChoiceList { + name: String::from("P"), + indexref: Some(1), + indexsubref: None, + choices: vec![String::from("a"), String::from("")] + })) + ); + assert_eq!( + FragmentContent::parse("{P 1:2 a b c}"), + Ok(FragmentContent::Choice(ChoiceList { + name: String::from("P"), + indexref: Some(1), + indexsubref: Some(2), + choices: vec![String::from("a"), String::from("b"), String::from("c")] + })) + ); + assert_eq!( + FragmentContent::parse(r##"{P 1:2 "" "" b}"##), + Ok(FragmentContent::Choice(ChoiceList { + name: String::from("P"), + indexref: Some(1), + indexsubref: Some(2), + choices: vec![String::from(""), String::from(""), String::from("b")] + })) + ); + assert_eq!( + FragmentContent::parse(r##"{P 1:2 a ""}"##), + Ok(FragmentContent::Choice(ChoiceList { + name: String::from("P"), + indexref: Some(1), + indexsubref: Some(2), + choices: vec![String::from("a"), String::from("")] + })) + ); + } + + #[test] + fn test_parse_cmd_err() { + assert!(FragmentContent::parse("{1}").is_err()); + assert!(FragmentContent::parse("{1:1}").is_err()); + assert!(FragmentContent::parse("{1:1 NUM}").is_err()); + assert!(FragmentContent::parse("{NUM=a}").is_err()); + assert!(FragmentContent::parse(r##"{P " a}"##).is_err()); + assert!(FragmentContent::parse(r##"{P 1.a a b}"##).is_err()); + assert!(FragmentContent::parse(r##"{P 1:a a b}"##).is_err()); + } + + #[test] + fn test_compile_cmd() { + assert_eq!( + StringCommand { + index: None, + name: String::from(""), + case: None + } + .compile(), + "{}" + ); + assert_eq!( + StringCommand { + index: None, + name: String::from("{"), + case: None + } + .compile(), + "{{}" + ); + assert_eq!( + StringCommand { + index: None, + name: String::from("BIG_FONT"), + case: None + } + .compile(), + "{BIG_FONT}" + ); + assert_eq!( + StringCommand { + index: Some(1), + name: String::from("STRING"), + case: Some(String::from("gen")) + } + .compile(), + "{1:STRING.gen}" + ); + assert_eq!( + GenderDefinition { + gender: String::from("n") + } + .compile(), + "{G=n}" + ); + assert_eq!( + ChoiceList { + name: String::from("P"), + indexref: None, + indexsubref: None, + choices: vec![String::from("a"), String::from("b")] + } + .compile(), + "{P a b}" + ); + assert_eq!( + ChoiceList { + name: String::from("P"), + indexref: None, + indexsubref: None, + choices: vec![String::from(""), String::from(" b")] + } + .compile(), + r##"{P "" " b"}"## + ); + assert_eq!( + ChoiceList { + name: String::from("P"), + indexref: Some(1), + indexsubref: None, + choices: vec![String::from("a"), String::from("b")] + } + .compile(), + "{P 1 a b}" + ); + assert_eq!( + ChoiceList { + name: String::from("P"), + indexref: Some(1), + indexsubref: Some(2), + choices: vec![String::from("a"), String::from("b")] + } + .compile(), + "{P 1:2 a b}" + ); + } + + #[test] + fn test_parse_str_empty() { + let case1 = ParsedString::parse(""); + assert!(case1.is_ok()); + let case1 = case1.unwrap(); + assert!(case1.fragments.is_empty()); + } + + #[test] + fn test_parse_str_ok() { + let case1 = ParsedString::parse( + "{G=n}{ORANGE}\u{039f}\u{03c0}\u{03b7}\u{03bd}\u{03a4}\u{03a4}\u{0394} {STRING}", + ); + assert!(case1.is_ok()); + let case1 = case1.unwrap(); + assert_eq!( + case1.fragments, + vec![ + StringFragment { + pos_begin: 0, + pos_end: 5, + content: FragmentContent::Gender(GenderDefinition { + gender: String::from("n") + }) + }, + StringFragment { + pos_begin: 5, + pos_end: 13, + content: FragmentContent::Command(StringCommand { + index: None, + name: String::from("ORANGE"), + case: None + }) + }, + StringFragment { + pos_begin: 13, + pos_end: 21, + content: FragmentContent::Text(String::from( + "\u{039f}\u{03c0}\u{03b7}\u{03bd}\u{03a4}\u{03a4}\u{0394} " + )) + }, + StringFragment { + pos_begin: 21, + pos_end: 29, + content: FragmentContent::Command(StringCommand { + index: None, + name: String::from("STRING"), + case: None + }) + }, + ] + ); + } + + #[test] + fn test_parse_str_err() { + let case1 = ParsedString::parse("{G=n}{ORANGE OpenTTD"); + assert_eq!( + case1.err(), + Some(ParserError { + pos_begin: 5, + pos_end: None, + message: String::from("Unterminated string command, '}' expected."), + }) + ); + } +} diff --git a/src/validate.rs b/src/validate.rs index dedddc7..cb888e0 100644 --- a/src/validate.rs +++ b/src/validate.rs @@ -1,33 +1,1585 @@ +use crate::commands::{CommandInfo, Dialect, Occurence, COMMANDS}; +use crate::parser::{FragmentContent, ParsedString}; use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, HashMap, HashSet}; #[derive(Deserialize, Debug)] pub struct LanguageConfig { + pub dialect: String, //< "newgrf", "game-script", "openttd" pub cases: Vec, pub genders: Vec, - pub plural_count: u32, + pub plural_count: usize, } -#[derive(Serialize, Debug)] +#[derive(Debug, PartialEq)] +pub enum Severity { + Error, //< translation is broken, do not commit. + Warning, //< translation has minor issues, but is probably better than no translation. +} + +#[derive(Serialize, Debug, PartialEq)] pub struct ValidationError { + pub severity: Severity, + pub pos_begin: Option, //< codepoint offset in input string + pub pos_end: Option, pub message: String, pub suggestion: Option, } +#[derive(Serialize, Debug)] +pub struct ValidationResult { + pub errors: Vec, + pub normalized: Option, +} + +impl LanguageConfig { + fn get_dialect(&self) -> Dialect { + self.dialect.as_str().into() + } + + pub fn allow_cases(&self) -> bool { + self.get_dialect() != Dialect::GAMESCRIPT + } + + fn allow_genders(&self) -> bool { + self.get_dialect() != Dialect::GAMESCRIPT + } +} + +impl Serialize for Severity { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(match self { + Self::Error => "error", + Self::Warning => "warning", + }) + } +} + +/** + * Validate whether a base string is valid. + * + * @param config The language configuration of the base language. (dialect and plural form) + * @param base The base string to validate. + * + * @returns A normalized form of the base string for translators, and a list of error messages, if the base is invalid. + */ +pub fn validate_base(config: LanguageConfig, base: String) -> ValidationResult { + let mut base = match ParsedString::parse(&base) { + Err(err) => { + return ValidationResult { + errors: vec![ValidationError { + severity: Severity::Error, + pos_begin: Some(err.pos_begin), + pos_end: err.pos_end, + message: err.message, + suggestion: None, + }], + normalized: None, + }; + } + Ok(parsed) => parsed, + }; + let errs = validate_string(&config, &base, None); + if errs.iter().any(|e| e.severity == Severity::Error) { + ValidationResult { + errors: errs, + normalized: None, + } + } else { + sanitize_whitespace(&mut base); + normalize_string(&config.get_dialect(), &mut base); + ValidationResult { + errors: errs, + normalized: Some(base.compile()), + } + } +} + /** * Validate whether a translation is valid for the given base string. * * @param config The language configuration to validate against. * @param base The base string to validate against. - * @param case The case of the translation. + * @param case The case of the translation. Use "default" for the default case. * @param translation The translation to validate. * - * @returns A clear and specific error message if the translation is invalid. None otherwise. + * @returns A normalized form of the translation, and a list of error messages, if the translation is invalid. */ -pub fn validate( - _config: LanguageConfig, - _base: String, - _case: String, - _translation: String, -) -> Option { - None +pub fn validate_translation( + config: LanguageConfig, + base: String, + case: String, + translation: String, +) -> ValidationResult { + let base = match ParsedString::parse(&base) { + Err(_) => { + return ValidationResult { + errors: vec![ValidationError { + severity: Severity::Error, + pos_begin: None, + pos_end: None, + message: String::from("Base language text is invalid."), + suggestion: Some(String::from("This is a bug; wait until it is fixed.")), + }], + normalized: None, + }; + } + Ok(parsed) => parsed, + }; + if case != "default" { + if !config.allow_cases() { + return ValidationResult { + errors: vec![ValidationError { + severity: Severity::Error, + pos_begin: None, + pos_end: None, + message: String::from("No cases allowed."), + suggestion: None, + }], + normalized: None, + }; + } else if !config.cases.contains(&case) { + return ValidationResult { + errors: vec![ValidationError { + severity: Severity::Error, + pos_begin: None, + pos_end: None, + message: format!("Unknown case '{}'.", case), + suggestion: Some(format!("Known cases are: '{}'", config.cases.join("', '"))), + }], + normalized: None, + }; + } + } + let mut translation = match ParsedString::parse(&translation) { + Err(err) => { + return ValidationResult { + errors: vec![ValidationError { + severity: Severity::Error, + pos_begin: Some(err.pos_begin), + pos_end: err.pos_end, + message: err.message, + suggestion: None, + }], + normalized: None, + }; + } + Ok(parsed) => parsed, + }; + let errs = validate_string(&config, &translation, Some(&base)); + if errs.iter().any(|e| e.severity == Severity::Error) { + ValidationResult { + errors: errs, + normalized: None, + } + } else { + sanitize_whitespace(&mut translation); + normalize_string(&config.get_dialect(), &mut translation); + ValidationResult { + errors: errs, + normalized: Some(translation.compile()), + } + } +} + +fn remove_ascii_ctrl(t: &mut String) { + *t = t.replace(|c| char::is_ascii_control(&c), " "); +} + +fn remove_trailing_blanks(t: &mut String) { + if let Some(last) = t.rfind(|c| c != ' ') { + t.truncate(last + 1); + } +} + +/// Replace all ASCII control codes with blank. +/// Remove trailing blanks at end of each line. +fn sanitize_whitespace(parsed: &mut ParsedString) { + let mut is_eol = true; + for i in (0..parsed.fragments.len()).rev() { + let mut is_nl = false; + match &mut parsed.fragments[i].content { + FragmentContent::Text(t) => { + remove_ascii_ctrl(t); + if is_eol { + remove_trailing_blanks(t); + } + } + FragmentContent::Choice(c) => { + for t in &mut c.choices { + remove_ascii_ctrl(t); + } + } + FragmentContent::Command(c) => { + is_nl = c.name.is_empty(); + } + _ => (), + } + is_eol = is_nl; + } +} + +struct StringSignature { + parameters: HashMap>, + nonpositional_count: BTreeMap, + // TODO track color/lineno/colorstack for positional parameters +} + +fn get_signature( + dialect: &Dialect, + base: &ParsedString, +) -> Result> { + let mut errors = Vec::new(); + let mut signature = StringSignature { + parameters: HashMap::new(), + nonpositional_count: BTreeMap::new(), + }; + + let mut pos = 0; + for fragment in &base.fragments { + if let FragmentContent::Command(cmd) = &fragment.content { + if let Some(info) = COMMANDS + .into_iter() + .find(|ci| ci.name == cmd.name && ci.dialects.contains(&dialect)) + { + if info.parameters.is_empty() { + if let Some(index) = cmd.index { + errors.push(ValidationError { + severity: Severity::Error, + pos_begin: Some(fragment.pos_begin), + pos_end: Some(fragment.pos_end), + message: format!( + "Command '{{{}}}' cannot have a position reference.", + cmd.name + ), + suggestion: Some(format!("Remove '{}:'.", index)), + }); + } + let norm_name = String::from(info.get_norm_name()); + if let Some(existing) = signature.nonpositional_count.get_mut(&norm_name) { + existing.1 += 1; + } else { + signature + .nonpositional_count + .insert(norm_name, (info.occurence.clone(), 1)); + } + } else { + if let Some(index) = cmd.index { + pos = index; + } + if let Some(existing) = signature.parameters.insert(pos, info) { + errors.push(ValidationError { + severity: Severity::Error, + pos_begin: Some(fragment.pos_begin), + pos_end: Some(fragment.pos_end), + message: format!( + "Command '{{{}:{}}}' references the same position as '{{{}:{}}}' before.", + pos, cmd.name, pos, existing.name + ), + suggestion: Some(String::from("Assign unique position references.")), + }); + } + pos += 1; + } + } else { + errors.push(ValidationError { + severity: Severity::Error, + pos_begin: Some(fragment.pos_begin), + pos_end: Some(fragment.pos_end), + message: format!("Unknown string command '{{{}}}'.", cmd.name), + suggestion: None, + }); + } + } + } + + if errors.is_empty() { + Ok(signature) + } else { + Err(errors) + } +} + +fn validate_string( + config: &LanguageConfig, + test: &ParsedString, + base: Option<&ParsedString>, +) -> Vec { + let dialect = config.get_dialect(); + let signature: StringSignature; + match get_signature(&dialect, base.unwrap_or(test)) { + Ok(sig) => signature = sig, + Err(msgs) => { + if base.is_some() { + return vec![ValidationError { + severity: Severity::Error, + pos_begin: None, + pos_end: None, + message: String::from("Base language text is invalid."), + suggestion: Some(String::from("This is a bug; wait until it is fixed.")), + }]; + } else { + return msgs; + } + } + } + + let mut errors = Vec::new(); + let mut used_parameters = HashSet::new(); + let mut nonpositional_count: BTreeMap = BTreeMap::new(); + let mut pos = 0; + let mut front = 0; + for fragment in &test.fragments { + match &fragment.content { + FragmentContent::Command(cmd) => { + let opt_expected = signature + .parameters + .get(&cmd.index.unwrap_or(pos)) + .map(|v| *v); + let opt_info = + opt_expected + .filter(|ex| ex.get_norm_name() == cmd.name) + .or(COMMANDS + .into_iter() + .find(|ci| ci.name == cmd.name && ci.dialects.contains(&dialect))); + if let Some(info) = opt_info { + if info.front_only && front == 2 { + errors.push(ValidationError { + severity: Severity::Warning, + pos_begin: Some(fragment.pos_begin), + pos_end: Some(fragment.pos_end), + message: format!("Command '{{{}}}' must be at the front.", cmd.name), + suggestion: None, + }); + } + if let Some(c) = &cmd.case { + if !config.allow_cases() { + errors.push(ValidationError { + severity: Severity::Error, + pos_begin: Some(fragment.pos_begin), + pos_end: Some(fragment.pos_end), + message: String::from("No case selections allowed."), + suggestion: Some(format!("Remove '.{}'.", c)), + }); + } else if !info.allow_case { + errors.push(ValidationError { + severity: Severity::Error, + pos_begin: Some(fragment.pos_begin), + pos_end: Some(fragment.pos_end), + message: format!( + "No case selection allowed for '{{{}}}'.", + cmd.name + ), + suggestion: Some(format!("Remove '.{}'.", c)), + }); + } else if !config.cases.contains(&c) { + errors.push(ValidationError { + severity: Severity::Error, + pos_begin: Some(fragment.pos_begin), + pos_end: Some(fragment.pos_end), + message: format!("Unknown case '{}'.", c), + suggestion: Some(format!( + "Known cases are: '{}'", + config.cases.join("', '") + )), + }); + } + } + + if info.parameters.is_empty() { + if let Some(index) = cmd.index { + errors.push(ValidationError { + severity: Severity::Error, + pos_begin: Some(fragment.pos_begin), + pos_end: Some(fragment.pos_end), + message: format!( + "Command '{{{}}}' cannot have a position reference.", + cmd.name + ), + suggestion: Some(format!("Remove '{}:'.", index)), + }); + } + + let norm_name = String::from(info.get_norm_name()); + if let Some(existing) = nonpositional_count.get_mut(&norm_name) { + existing.1 += 1; + } else { + nonpositional_count.insert(norm_name, (info.occurence.clone(), 1)); + } + } else { + if let Some(index) = cmd.index { + pos = index; + } + + if !used_parameters.insert(pos) { + errors.push(ValidationError { + severity: Severity::Error, + pos_begin: Some(fragment.pos_begin), + pos_end: Some(fragment.pos_end), + message: format!("Duplicate parameter '{{{}:{}}}'.", pos, cmd.name), + suggestion: None, + }); + } + + match opt_expected { + None => errors.push(ValidationError { + severity: Severity::Error, + pos_begin: Some(fragment.pos_begin), + pos_end: Some(fragment.pos_end), + message: format!( + "There is no parameter in position {}, found '{{{}}}'.", + pos, cmd.name + ), + suggestion: None, + }), + Some(expected) if expected.get_norm_name() != info.get_norm_name() => { + errors.push(ValidationError { + severity: Severity::Error, + pos_begin: Some(fragment.pos_begin), + pos_end: Some(fragment.pos_end), + message: format!( + "Expected '{{{}:{}}}', found '{{{}}}'.", + pos, expected.name, cmd.name + ), + suggestion: None, + }) + } + _ => (), + } + + pos += 1; + } + } else { + errors.push(ValidationError { + severity: Severity::Error, + pos_begin: Some(fragment.pos_begin), + pos_end: Some(fragment.pos_end), + message: format!("Unknown string command '{{{}}}'.", cmd.name), + suggestion: None, + }); + } + front = 2; + } + FragmentContent::Gender(g) => { + if !config.allow_genders() || config.genders.len() < 2 { + errors.push(ValidationError { + severity: Severity::Error, + pos_begin: Some(fragment.pos_begin), + pos_end: Some(fragment.pos_end), + message: String::from("No gender definitions allowed."), + suggestion: Some(String::from("Remove '{G=...}'.")), + }); + } else if front == 2 { + errors.push(ValidationError { + severity: Severity::Warning, + pos_begin: Some(fragment.pos_begin), + pos_end: Some(fragment.pos_end), + message: String::from("Gender definitions must be at the front."), + suggestion: Some(String::from( + "Move '{G=...}' to the front of the translation.", + )), + }); + } else if front == 1 { + errors.push(ValidationError { + severity: Severity::Warning, + pos_begin: Some(fragment.pos_begin), + pos_end: Some(fragment.pos_end), + message: String::from("Duplicate gender definition."), + suggestion: Some(String::from("Remove the second '{G=...}'.")), + }); + } else { + front = 1; + if !config.genders.contains(&g.gender) { + errors.push(ValidationError { + severity: Severity::Error, + pos_begin: Some(fragment.pos_begin), + pos_end: Some(fragment.pos_end), + message: format!("Unknown gender '{}'.", g.gender), + suggestion: Some(format!( + "Known genders are: '{}'", + config.genders.join("', '") + )), + }); + } + } + } + FragmentContent::Choice(cmd) => { + let opt_ref_pos = match cmd.name.as_str() { + "P" => { + if pos == 0 { + None + } else { + Some(pos - 1) + } + } + "G" => Some(pos), + _ => panic!(), + }; + let opt_ref_pos = cmd.indexref.or(opt_ref_pos); + if cmd.name == "G" && (!config.allow_genders() || config.genders.len() < 2) { + errors.push(ValidationError { + severity: Severity::Error, + pos_begin: Some(fragment.pos_begin), + pos_end: Some(fragment.pos_end), + message: String::from("No gender choices allowed."), + suggestion: Some(String::from("Remove '{G ...}'.")), + }); + } else if cmd.name == "P" && config.plural_count < 2 { + errors.push(ValidationError { + severity: Severity::Error, + pos_begin: Some(fragment.pos_begin), + pos_end: Some(fragment.pos_end), + message: String::from("No plural choices allowed."), + suggestion: Some(String::from("Remove '{P ...}'.")), + }); + } else { + match cmd.name.as_str() { + "P" => { + if cmd.choices.len() != config.plural_count { + errors.push(ValidationError { + severity: Severity::Error, + pos_begin: Some(fragment.pos_begin), + pos_end: Some(fragment.pos_end), + message: format!( + "Expected {} plural choices, found {}.", + config.plural_count, + cmd.choices.len() + ), + suggestion: None, + }); + } + } + "G" => { + if cmd.choices.len() != config.genders.len() { + errors.push(ValidationError { + severity: Severity::Error, + pos_begin: Some(fragment.pos_begin), + pos_end: Some(fragment.pos_end), + message: format!( + "Expected {} gender choices, found {}.", + config.genders.len(), + cmd.choices.len() + ), + suggestion: None, + }); + } + } + _ => panic!(), + }; + + if let Some(ref_info) = + opt_ref_pos.and_then(|ref_pos| signature.parameters.get(&ref_pos)) + { + let ref_pos = opt_ref_pos.unwrap(); + let ref_norm_name = ref_info.get_norm_name(); + let ref_subpos = match cmd.name.as_str() { + "P" => cmd + .indexsubref + .or(ref_info.def_plural_subindex) + .unwrap_or(0), + "G" => cmd.indexsubref.unwrap_or(0), + _ => panic!(), + }; + if let Some(par_info) = ref_info.parameters.get(ref_subpos) { + match cmd.name.as_str() { + "P" => { + if !par_info.allow_plural { + errors.push(ValidationError{ + severity: Severity::Error, + pos_begin: Some(fragment.pos_begin), + pos_end: Some(fragment.pos_end), + message: format!( + "'{{{}}}' references position '{}:{}', but '{{{}:{}}}' does not allow plurals.", + cmd.name, ref_pos, ref_subpos, ref_pos, ref_norm_name + ), + suggestion: None, + }); + } + } + "G" => { + if !par_info.allow_gender { + errors.push(ValidationError{ + severity: Severity::Error, + pos_begin: Some(fragment.pos_begin), + pos_end: Some(fragment.pos_end), + message: format!( + "'{{{}}}' references position '{}:{}', but '{{{}:{}}}' does not allow genders.", + cmd.name, ref_pos, ref_subpos, ref_pos, ref_norm_name + ), + suggestion: None, + }); + } + } + _ => panic!(), + }; + } else { + errors.push(ValidationError{ + severity: Severity::Error, + pos_begin: Some(fragment.pos_begin), + pos_end: Some(fragment.pos_end), + message: format!( + "'{{{}}}' references position '{}:{}', but '{{{}:{}}}' only has {} subindices.", + cmd.name, ref_pos, ref_subpos, ref_pos, ref_norm_name, ref_info.parameters.len() + ), + suggestion: None, + }); + } + } else { + errors.push(ValidationError { + severity: Severity::Error, + pos_begin: Some(fragment.pos_begin), + pos_end: Some(fragment.pos_end), + message: format!( + "'{{{}}}' references position '{}', which has no parameter.", + cmd.name, + opt_ref_pos + .and_then(|v| isize::try_from(v).ok()) + .unwrap_or(-1) + ), + suggestion: if cmd.indexref.is_none() { + Some(String::from("Add a position reference.")) + } else { + None + }, + }); + } + } + front = 2; + } + FragmentContent::Text(_) => { + front = 2; + } + } + } + + for (pos, info) in &signature.parameters { + if !used_parameters.contains(pos) { + let norm_name = info.get_norm_name(); + errors.push(ValidationError { + severity: Severity::Error, + pos_begin: None, + pos_end: None, + message: format!("String command '{{{}:{}}}' is missing.", pos, norm_name), + suggestion: None, + }); + } + } + + for (norm_name, (occurence, ex_count)) in &signature.nonpositional_count { + let found_count = nonpositional_count.get(norm_name).map(|v| v.1).unwrap_or(0); + if *occurence != Occurence::ANY && found_count == 0 { + errors.push(ValidationError { + severity: Severity::Warning, + pos_begin: None, + pos_end: None, + message: format!("String command '{{{}}}' is missing.", norm_name), + suggestion: None, + }); + } else if *occurence == Occurence::EXACT && *ex_count != found_count { + errors.push(ValidationError { + severity: Severity::Warning, + pos_begin: None, + pos_end: None, + message: format!( + "String command '{{{}}}': expected {} times, found {} times.", + norm_name, ex_count, found_count + ), + suggestion: None, + }); + } + } + for (norm_name, (occurence, _)) in &nonpositional_count { + if *occurence != Occurence::ANY && signature.nonpositional_count.get(norm_name).is_none() { + errors.push(ValidationError { + severity: Severity::Warning, + pos_begin: None, + pos_end: None, + message: format!("String command '{{{}}}' is unexpected.", norm_name), + suggestion: Some(String::from("Remove this command.")), + }); + } + } + + errors +} + +fn normalize_string(dialect: &Dialect, parsed: &mut ParsedString) { + let mut parameters = HashMap::new(); + + let mut pos = 0; + for fragment in &mut parsed.fragments { + match &mut fragment.content { + FragmentContent::Command(cmd) => { + if let Some(info) = COMMANDS + .into_iter() + .find(|ci| ci.name == cmd.name && ci.dialects.contains(&dialect)) + { + if let Some(norm_name) = info.norm_name { + // normalize name + cmd.name = String::from(norm_name); + } + if !info.parameters.is_empty() { + if let Some(index) = cmd.index { + pos = index; + } else { + // add missing indices + cmd.index = Some(pos); + } + parameters.insert(pos, info); + pos += 1; + } + } + } + FragmentContent::Choice(cmd) => { + match cmd.name.as_str() { + "P" => { + if cmd.indexref.is_none() && pos > 0 { + // add missing indices + cmd.indexref = Some(pos - 1); + } + } + "G" => { + if cmd.indexref.is_none() { + // add missing indices + cmd.indexref = Some(pos); + } + } + _ => panic!(), + }; + } + _ => (), + } + } + + for fragment in &mut parsed.fragments { + if let FragmentContent::Choice(cmd) = &mut fragment.content { + if let Some(ref_info) = cmd.indexref.and_then(|pos| parameters.get(&pos)) { + if cmd.indexsubref == ref_info.def_plural_subindex.or(Some(0)) { + // remove subindex, if default + cmd.indexsubref = None; + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_sanitize() { + let mut s1 = String::from(""); + let mut s2 = String::from(" a b c "); + let mut s3 = String::from("\0a\tb\rc\r\n"); + remove_ascii_ctrl(&mut s1); + remove_ascii_ctrl(&mut s2); + remove_ascii_ctrl(&mut s3); + assert_eq!(s1, String::from("")); + assert_eq!(s2, String::from(" a b c ")); + assert_eq!(s3, String::from(" a b c ")); + remove_trailing_blanks(&mut s1); + remove_trailing_blanks(&mut s2); + remove_trailing_blanks(&mut s3); + assert_eq!(s1, String::from("")); + assert_eq!(s2, String::from(" a b c")); + assert_eq!(s3, String::from(" a b c")); + } + + #[test] + fn test_signature_empty() { + let parsed = ParsedString::parse("").unwrap(); + let sig = get_signature(&Dialect::OPENTTD, &parsed).unwrap(); + assert!(sig.parameters.is_empty()); + assert!(sig.nonpositional_count.is_empty()); + } + + #[test] + fn test_signature_pos() { + let parsed = ParsedString::parse("{P a b}{RED}{NUM}{NBSP}{MONO_FONT}{5:STRING.foo}{RED}{2:STRING3.bar}{RAW_STRING}{G c d}").unwrap(); + let sig = get_signature(&Dialect::OPENTTD, &parsed).unwrap(); + assert_eq!(sig.parameters.len(), 4); + assert_eq!(sig.parameters.get(&0).unwrap().name, "NUM"); + assert_eq!(sig.parameters.get(&5).unwrap().name, "STRING"); + assert_eq!(sig.parameters.get(&2).unwrap().name, "STRING3"); + assert_eq!(sig.parameters.get(&3).unwrap().name, "RAW_STRING"); + assert_eq!(sig.nonpositional_count.len(), 3); + assert_eq!( + sig.nonpositional_count.get("RED"), + Some(&(Occurence::NONZERO, 2)) + ); + assert_eq!( + sig.nonpositional_count.get("MONO_FONT"), + Some(&(Occurence::EXACT, 1)) + ); + assert_eq!( + sig.nonpositional_count.get("NBSP"), + Some(&(Occurence::ANY, 1)) + ); + } + + #[test] + fn test_signature_dup() { + let parsed = ParsedString::parse("{NUM}{0:COMMA}").unwrap(); + let err = get_signature(&Dialect::OPENTTD, &parsed).err().unwrap(); + assert_eq!(err.len(), 1); + assert_eq!( + err[0], + ValidationError { + severity: Severity::Error, + pos_begin: Some(5), + pos_end: Some(14), + message: String::from( + "Command '{0:COMMA}' references the same position as '{0:NUM}' before." + ), + suggestion: Some(String::from("Assign unique position references.")), + } + ); + } + + #[test] + fn test_signature_dialect() { + let parsed = ParsedString::parse("{RAW_STRING}").unwrap(); + + let sig = get_signature(&Dialect::OPENTTD, &parsed).unwrap(); + assert_eq!(sig.parameters.len(), 1); + assert_eq!(sig.parameters.get(&0).unwrap().name, "RAW_STRING"); + assert_eq!(sig.nonpositional_count.len(), 0); + + let err = get_signature(&Dialect::NEWGRF, &parsed).err().unwrap(); + assert_eq!(err.len(), 1); + assert_eq!( + err[0], + ValidationError { + severity: Severity::Error, + pos_begin: Some(0), + pos_end: Some(12), + message: String::from("Unknown string command '{RAW_STRING}'."), + suggestion: None, + } + ); + } + + #[test] + fn test_signature_unknown() { + let parsed = ParsedString::parse("{FOOBAR}").unwrap(); + let err = get_signature(&Dialect::OPENTTD, &parsed).err().unwrap(); + assert_eq!(err.len(), 1); + assert_eq!( + err[0], + ValidationError { + severity: Severity::Error, + pos_begin: Some(0), + pos_end: Some(8), + message: String::from("Unknown string command '{FOOBAR}'."), + suggestion: None, + } + ); + } + + #[test] + fn test_signature_nonpos() { + let parsed = ParsedString::parse("{1:RED}").unwrap(); + let err = get_signature(&Dialect::OPENTTD, &parsed).err().unwrap(); + assert_eq!(err.len(), 1); + assert_eq!( + err[0], + ValidationError { + severity: Severity::Error, + pos_begin: Some(0), + pos_end: Some(7), + message: String::from("Command '{RED}' cannot have a position reference."), + suggestion: Some(String::from("Remove '1:'.")), + } + ); + } + + #[test] + fn test_validate_empty() { + let config = LanguageConfig { + dialect: String::from("openttd"), + cases: vec![], + genders: vec![], + plural_count: 0, + }; + let base = ParsedString::parse("").unwrap(); + + let val_base = validate_string(&config, &base, None); + assert_eq!(val_base.len(), 0); + + let val_trans = validate_string(&config, &base, Some(&base)); + assert_eq!(val_trans.len(), 0); + } + + #[test] + fn test_validate_invalid() { + let config = LanguageConfig { + dialect: String::from("openttd"), + cases: vec![], + genders: vec![], + plural_count: 0, + }; + let base = ParsedString::parse("{FOOBAR}").unwrap(); + + let val_base = validate_string(&config, &base, None); + assert_eq!(val_base.len(), 1); + assert_eq!( + val_base[0], + ValidationError { + severity: Severity::Error, + pos_begin: Some(0), + pos_end: Some(8), + message: String::from("Unknown string command '{FOOBAR}'."), + suggestion: None, + } + ); + + let val_trans = validate_string(&config, &base, Some(&base)); + assert_eq!(val_trans.len(), 1); + assert_eq!( + val_trans[0], + ValidationError { + severity: Severity::Error, + pos_begin: None, + pos_end: None, + message: String::from("Base language text is invalid."), + suggestion: Some(String::from("This is a bug; wait until it is fixed.")), + } + ); + } + + #[test] + fn test_validate_positional() { + let config = LanguageConfig { + dialect: String::from("openttd"), + cases: vec![], + genders: vec![], + plural_count: 0, + }; + let base = ParsedString::parse("{NUM}").unwrap(); + let val_base = validate_string(&config, &base, None); + assert_eq!(val_base.len(), 0); + + { + let trans = ParsedString::parse("{0:NUM}").unwrap(); + let val_trans = validate_string(&config, &trans, Some(&base)); + assert_eq!(val_trans.len(), 0); + } + { + let trans = ParsedString::parse("{FOOBAR}{NUM}").unwrap(); + let val_trans = validate_string(&config, &trans, Some(&base)); + assert_eq!(val_trans.len(), 1); + assert_eq!( + val_trans[0], + ValidationError { + severity: Severity::Error, + pos_begin: Some(0), + pos_end: Some(8), + message: String::from("Unknown string command '{FOOBAR}'."), + suggestion: None, + } + ); + } + { + let trans = ParsedString::parse("{1:NUM}").unwrap(); + let val_trans = validate_string(&config, &trans, Some(&base)); + assert_eq!(val_trans.len(), 2); + assert_eq!( + val_trans[0], + ValidationError { + severity: Severity::Error, + pos_begin: Some(0), + pos_end: Some(7), + message: String::from("There is no parameter in position 1, found '{NUM}'."), + suggestion: None, + } + ); + assert_eq!( + val_trans[1], + ValidationError { + severity: Severity::Error, + pos_begin: None, + pos_end: None, + message: String::from("String command '{0:NUM}' is missing."), + suggestion: None, + } + ); + } + { + let trans = ParsedString::parse("{COMMA}").unwrap(); + let val_trans = validate_string(&config, &trans, Some(&base)); + assert_eq!(val_trans.len(), 1); + assert_eq!( + val_trans[0], + ValidationError { + severity: Severity::Error, + pos_begin: Some(0), + pos_end: Some(7), + message: String::from("Expected '{0:NUM}', found '{COMMA}'."), + suggestion: None, + } + ); + } + { + let trans = ParsedString::parse("{0:NUM}{0:NUM}").unwrap(); + let val_trans = validate_string(&config, &trans, Some(&base)); + assert_eq!(val_trans.len(), 1); + assert_eq!( + val_trans[0], + ValidationError { + severity: Severity::Error, + pos_begin: Some(7), + pos_end: Some(14), + message: String::from("Duplicate parameter '{0:NUM}'."), + suggestion: None, + } + ); + } + } + + #[test] + fn test_validate_front() { + let config = LanguageConfig { + dialect: String::from("openttd"), + cases: vec![], + genders: vec![String::from("a"), String::from("b")], + plural_count: 0, + }; + let base = ParsedString::parse("{BIG_FONT}foo{NUM}").unwrap(); + let val_base = validate_string(&config, &base, None); + assert_eq!(val_base.len(), 0); + + { + let trans = ParsedString::parse("{G=a}{BIG_FONT}bar{NUM}").unwrap(); + let val_trans = validate_string(&config, &trans, Some(&base)); + assert_eq!(val_trans.len(), 0); + } + { + let trans = ParsedString::parse("{G=a}{G=a}{BIG_FONT}bar{NUM}").unwrap(); + let val_trans = validate_string(&config, &trans, Some(&base)); + assert_eq!(val_trans.len(), 1); + assert_eq!( + val_trans[0], + ValidationError { + severity: Severity::Warning, + pos_begin: Some(5), + pos_end: Some(10), + message: String::from("Duplicate gender definition."), + suggestion: Some(String::from("Remove the second '{G=...}'.")), + } + ); + } + { + let trans = ParsedString::parse("{BIG_FONT}{G=a}bar{NUM}").unwrap(); + let val_trans = validate_string(&config, &trans, Some(&base)); + assert_eq!(val_trans.len(), 1); + assert_eq!( + val_trans[0], + ValidationError { + severity: Severity::Warning, + pos_begin: Some(10), + pos_end: Some(15), + message: String::from("Gender definitions must be at the front."), + suggestion: Some(String::from( + "Move '{G=...}' to the front of the translation." + )), + } + ); + } + { + let trans = ParsedString::parse("foo{BIG_FONT}bar{NUM}").unwrap(); + let val_trans = validate_string(&config, &trans, Some(&base)); + assert_eq!(val_trans.len(), 1); + assert_eq!( + val_trans[0], + ValidationError { + severity: Severity::Warning, + pos_begin: Some(3), + pos_end: Some(13), + message: String::from("Command '{BIG_FONT}' must be at the front."), + suggestion: None, + } + ); + } + { + let trans = ParsedString::parse("foo{G=a}bar{NUM}").unwrap(); + let val_trans = validate_string(&config, &trans, Some(&base)); + assert_eq!(val_trans.len(), 2); + assert_eq!( + val_trans[0], + ValidationError { + severity: Severity::Warning, + pos_begin: Some(3), + pos_end: Some(8), + message: String::from("Gender definitions must be at the front."), + suggestion: Some(String::from( + "Move '{G=...}' to the front of the translation." + )), + } + ); + assert_eq!( + val_trans[1], + ValidationError { + severity: Severity::Warning, + pos_begin: None, + pos_end: None, + message: String::from("String command '{BIG_FONT}' is missing."), + suggestion: None, + } + ); + } + } + + #[test] + fn test_validate_position_references() { + let config = LanguageConfig { + dialect: String::from("openttd"), + cases: vec![String::from("x"), String::from("y")], + genders: vec![String::from("a"), String::from("b")], + plural_count: 2, + }; + let base = ParsedString::parse("{RED}{NUM}{STRING3}").unwrap(); + let val_base = validate_string(&config, &base, None); + assert_eq!(val_base.len(), 0); + + { + let trans = ParsedString::parse("{RED}{1:STRING.x}{0:NUM}").unwrap(); + let val_trans = validate_string(&config, &trans, Some(&base)); + assert_eq!(val_trans.len(), 0); + } + { + let trans = ParsedString::parse("{2:RED}{1:STRING.z}{0:NUM.x}").unwrap(); + let val_trans = validate_string(&config, &trans, Some(&base)); + assert_eq!(val_trans.len(), 3); + assert_eq!( + val_trans[0], + ValidationError { + severity: Severity::Error, + pos_begin: Some(0), + pos_end: Some(7), + message: String::from("Command '{RED}' cannot have a position reference."), + suggestion: Some(String::from("Remove '2:'.")), + } + ); + assert_eq!( + val_trans[1], + ValidationError { + severity: Severity::Error, + pos_begin: Some(7), + pos_end: Some(19), + message: String::from("Unknown case 'z'."), + suggestion: Some(String::from("Known cases are: 'x', 'y'")), + } + ); + assert_eq!( + val_trans[2], + ValidationError { + severity: Severity::Error, + pos_begin: Some(19), + pos_end: Some(28), + message: String::from("No case selection allowed for '{NUM}'."), + suggestion: Some(String::from("Remove '.x'.")), + } + ); + } + { + let trans = ParsedString::parse("{RED}{NUM}{G i j}{P i j}{STRING.y}").unwrap(); + let val_trans = validate_string(&config, &trans, Some(&base)); + assert_eq!(val_trans.len(), 0); + } + { + let trans = ParsedString::parse("{RED}{NUM}{G 0 i j}{P 1 i j}{STRING.y}").unwrap(); + let val_trans = validate_string(&config, &trans, Some(&base)); + assert_eq!(val_trans.len(), 2); + assert_eq!( + val_trans[0], + ValidationError { + severity: Severity::Error, + pos_begin: Some(10), + pos_end: Some(19), + message: String::from( + "'{G}' references position '0:0', but '{0:NUM}' does not allow genders." + ), + suggestion: None, + } + ); + assert_eq!( + val_trans[1], + ValidationError { + severity: Severity::Error, + pos_begin: Some(19), + pos_end: Some(28), + message: String::from( + "'{P}' references position '1:0', but '{1:STRING}' does not allow plurals." + ), + suggestion: None, + } + ); + } + { + let trans = ParsedString::parse("{RED}{NUM}{G 1:1 i j}{P 1:3 i j}{STRING.y}").unwrap(); + let val_trans = validate_string(&config, &trans, Some(&base)); + assert_eq!(val_trans.len(), 0); + } + { + let trans = ParsedString::parse("{RED}{NUM}{G 1:4 i j}{P 1:4 i j}{STRING.y}").unwrap(); + let val_trans = validate_string(&config, &trans, Some(&base)); + assert_eq!(val_trans.len(), 2); + assert_eq!( + val_trans[0], + ValidationError { + severity: Severity::Error, + pos_begin: Some(10), + pos_end: Some(21), + message: String::from( + "'{G}' references position '1:4', but '{1:STRING}' only has 4 subindices." + ), + suggestion: None, + } + ); + assert_eq!( + val_trans[1], + ValidationError { + severity: Severity::Error, + pos_begin: Some(21), + pos_end: Some(32), + message: String::from( + "'{P}' references position '1:4', but '{1:STRING}' only has 4 subindices." + ), + suggestion: None, + } + ); + } + { + let trans = ParsedString::parse("{RED}{NUM}{G 2 i j}{P 2 i j}{STRING.y}").unwrap(); + let val_trans = validate_string(&config, &trans, Some(&base)); + assert_eq!(val_trans.len(), 2); + assert_eq!( + val_trans[0], + ValidationError { + severity: Severity::Error, + pos_begin: Some(10), + pos_end: Some(19), + message: String::from("'{G}' references position '2', which has no parameter."), + suggestion: None, + } + ); + assert_eq!( + val_trans[1], + ValidationError { + severity: Severity::Error, + pos_begin: Some(19), + pos_end: Some(28), + message: String::from("'{P}' references position '2', which has no parameter."), + suggestion: None, + } + ); + } + { + let trans = ParsedString::parse("{RED}{P i j}{NUM}{STRING.y}{G i j}").unwrap(); + let val_trans = validate_string(&config, &trans, Some(&base)); + assert_eq!(val_trans.len(), 2); + assert_eq!( + val_trans[0], + ValidationError { + severity: Severity::Error, + pos_begin: Some(5), + pos_end: Some(12), + message: String::from( + "'{P}' references position '-1', which has no parameter." + ), + suggestion: Some(String::from("Add a position reference.")), + } + ); + assert_eq!( + val_trans[1], + ValidationError { + severity: Severity::Error, + pos_begin: Some(27), + pos_end: Some(34), + message: String::from("'{G}' references position '2', which has no parameter."), + suggestion: Some(String::from("Add a position reference.")), + } + ); + } + } + + #[test] + fn test_validate_nochoices() { + let config = LanguageConfig { + dialect: String::from("openttd"), + cases: vec![], + genders: vec![], + plural_count: 1, + }; + let base = ParsedString::parse("{NUM}{STRING3}").unwrap(); + let val_base = validate_string(&config, &base, None); + assert_eq!(val_base.len(), 0); + + { + let trans = ParsedString::parse("{G=a}{NUM}{P a}{G a}{STRING}").unwrap(); + let val_trans = validate_string(&config, &trans, Some(&base)); + assert_eq!(val_trans.len(), 3); + assert_eq!( + val_trans[0], + ValidationError { + severity: Severity::Error, + pos_begin: Some(0), + pos_end: Some(5), + message: String::from("No gender definitions allowed."), + suggestion: Some(String::from("Remove '{G=...}'.")), + } + ); + assert_eq!( + val_trans[1], + ValidationError { + severity: Severity::Error, + pos_begin: Some(10), + pos_end: Some(15), + message: String::from("No plural choices allowed."), + suggestion: Some(String::from("Remove '{P ...}'.")), + } + ); + assert_eq!( + val_trans[2], + ValidationError { + severity: Severity::Error, + pos_begin: Some(15), + pos_end: Some(20), + message: String::from("No gender choices allowed."), + suggestion: Some(String::from("Remove '{G ...}'.")), + } + ); + } + } + + #[test] + fn test_validate_gschoices() { + let config = LanguageConfig { + dialect: String::from("game-script"), + cases: vec![String::from("x"), String::from("y")], + genders: vec![String::from("a"), String::from("b")], + plural_count: 2, + }; + let base = ParsedString::parse("{NUM}{STRING3}").unwrap(); + let val_base = validate_string(&config, &base, None); + assert_eq!(val_base.len(), 0); + + { + let trans = ParsedString::parse("{G=a}{NUM}{P a b}{G a b}{STRING.x}").unwrap(); + let val_trans = validate_string(&config, &trans, Some(&base)); + assert_eq!(val_trans.len(), 3); + assert_eq!( + val_trans[0], + ValidationError { + severity: Severity::Error, + pos_begin: Some(0), + pos_end: Some(5), + message: String::from("No gender definitions allowed."), + suggestion: Some(String::from("Remove '{G=...}'.")), + } + ); + assert_eq!( + val_trans[1], + ValidationError { + severity: Severity::Error, + pos_begin: Some(17), + pos_end: Some(24), + message: String::from("No gender choices allowed."), + suggestion: Some(String::from("Remove '{G ...}'.")), + } + ); + assert_eq!( + val_trans[2], + ValidationError { + severity: Severity::Error, + pos_begin: Some(24), + pos_end: Some(34), + message: String::from("No case selections allowed."), + suggestion: Some(String::from("Remove '.x'.")), + } + ); + } + } + + #[test] + fn test_validate_choices() { + let config = LanguageConfig { + dialect: String::from("openttd"), + cases: vec![String::from("x"), String::from("y")], + genders: vec![String::from("a"), String::from("b")], + plural_count: 2, + }; + let base = ParsedString::parse("{NUM}{STRING3}").unwrap(); + let val_base = validate_string(&config, &base, None); + assert_eq!(val_base.len(), 0); + + { + let trans = ParsedString::parse("{G=a}{NUM}{P a b}{G a b}{STRING.x}").unwrap(); + let val_trans = validate_string(&config, &trans, Some(&base)); + assert_eq!(val_trans.len(), 0); + } + { + let trans = ParsedString::parse("{G=c}{NUM}{P a b c}{G a b c}{STRING.z}").unwrap(); + let val_trans = validate_string(&config, &trans, Some(&base)); + assert_eq!(val_trans.len(), 4); + assert_eq!( + val_trans[0], + ValidationError { + severity: Severity::Error, + pos_begin: Some(0), + pos_end: Some(5), + message: String::from("Unknown gender 'c'."), + suggestion: Some(String::from("Known genders are: 'a', 'b'")), + } + ); + assert_eq!( + val_trans[1], + ValidationError { + severity: Severity::Error, + pos_begin: Some(10), + pos_end: Some(19), + message: String::from("Expected 2 plural choices, found 3."), + suggestion: None, + } + ); + assert_eq!( + val_trans[2], + ValidationError { + severity: Severity::Error, + pos_begin: Some(19), + pos_end: Some(28), + message: String::from("Expected 2 gender choices, found 3."), + suggestion: None, + } + ); + assert_eq!( + val_trans[3], + ValidationError { + severity: Severity::Error, + pos_begin: Some(28), + pos_end: Some(38), + message: String::from("Unknown case 'z'."), + suggestion: Some(String::from("Known cases are: 'x', 'y'")), + } + ); + } + } + + #[test] + fn test_validate_nonpositional() { + let config = LanguageConfig { + dialect: String::from("openttd"), + cases: vec![], + genders: vec![], + plural_count: 0, + }; + let base = ParsedString::parse("{RED}{NBSP}{}{GREEN}{NBSP}{}{RED}{TRAIN}").unwrap(); + let val_base = validate_string(&config, &base, None); + assert_eq!(val_base.len(), 0); + + { + let trans = ParsedString::parse("{RED}{}{GREEN}{}{RED}{TRAIN}").unwrap(); + let val_trans = validate_string(&config, &trans, Some(&base)); + assert_eq!(val_trans.len(), 0); + } + { + let trans = ParsedString::parse("{RED}{}{GREEN}{NBSP}{RED}{NBSP}{GREEN}{}{RED}{TRAIN}") + .unwrap(); + let val_trans = validate_string(&config, &trans, Some(&base)); + assert_eq!(val_trans.len(), 0); + } + { + let trans = + ParsedString::parse("{RED}{}{RED}{TRAIN}{BLUE}{TRAIN}{RIGHT_ARROW}{SHIP}").unwrap(); + let val_trans = validate_string(&config, &trans, Some(&base)); + assert_eq!(val_trans.len(), 5); + assert_eq!( + val_trans[0], + ValidationError { + severity: Severity::Warning, + pos_begin: None, + pos_end: None, + message: String::from("String command '{}': expected 2 times, found 1 times."), + suggestion: None, + } + ); + assert_eq!( + val_trans[1], + ValidationError { + severity: Severity::Warning, + pos_begin: None, + pos_end: None, + message: String::from("String command '{GREEN}' is missing."), + suggestion: None, + } + ); + assert_eq!( + val_trans[2], + ValidationError { + severity: Severity::Warning, + pos_begin: None, + pos_end: None, + message: String::from( + "String command '{TRAIN}': expected 1 times, found 2 times." + ), + suggestion: None, + } + ); + assert_eq!( + val_trans[3], + ValidationError { + severity: Severity::Warning, + pos_begin: None, + pos_end: None, + message: String::from("String command '{BLUE}' is unexpected."), + suggestion: Some(String::from("Remove this command.")), + } + ); + assert_eq!( + val_trans[4], + ValidationError { + severity: Severity::Warning, + pos_begin: None, + pos_end: None, + message: String::from("String command '{SHIP}' is unexpected."), + suggestion: Some(String::from("Remove this command.")), + } + ); + } + } + + #[test] + fn test_normalize_cmd() { + let mut parsed = + ParsedString::parse("{RED}{NBSP}{2:RAW_STRING}{0:STRING5}{COMMA}").unwrap(); + normalize_string(&Dialect::OPENTTD, &mut parsed); + let result = parsed.compile(); + assert_eq!(result, "{RED}{NBSP}{2:STRING}{0:STRING}{1:COMMA}"); + } + + #[test] + fn test_normalize_ref() { + 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(); + normalize_string(&Dialect::OPENTTD, &mut parsed); + let result = parsed.compile(); + 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}"); + } + + #[test] + fn test_normalize_subref() { + let mut parsed = ParsedString::parse( + "{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}", + ) + .unwrap(); + normalize_string(&Dialect::OPENTTD, &mut parsed); + let result = parsed.compile(); + assert_eq!( + result, + "{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}" + ); + } }