diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..5d71c59 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,26 @@ +Copyright (c) 2024 Mikko Ahlroth + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +--- + +The plural forms tokenizer and evaluator are translated from the Expo package, used under the following +licence: + +Copyright 2015 Plataformatec Copyright 2020 Dashbit 2022 JOSHMARTIN GmbH + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/README.md b/README.md index 4764f2a..3834a7a 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,95 @@ -# kielet +# Kielet - GNU Gettext for Gleam [![Package Version](https://img.shields.io/hexpm/v/kielet)](https://hex.pm/packages/kielet) [![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](https://hexdocs.pm/kielet/) +Kielet is a [GNU Gettext](https://www.gnu.org/software/gettext/) implementation for Gleam. With Kielet, +you can translate your Gleam or BEAM application without needing to change its source code or recompile +it. Kielet implements translation functions for singular and plural forms and an MO file parser to read +compiled translations. Gleam source code can be processed with the `xgettext` program to automatically +generate the translation templates (POT files). + +## Usage + +First add Kielet to your project: + ```sh gleam add kielet ``` + +Then you can start annotating your source code to prepare it for translation (skip the file reading and +language loading when you don't have languages yet): + ```gleam -import kielet +import gleam/io +import kielet.{gettext as g_, ngettext as n_} +import kielet/context.{Context} +import kielet/database +import kielet/language +import simplifile pub fn main() { - // TODO: An example of the project in use + // This example uses simplifile to read the MO data + let assert Ok(mo_data) = simplifile.read_bits("./path/to/fi.mo") + + // Load language from MO file + let assert Ok(finnish) = language.load("fi", mo_data) + + // Create language database + let db = database.new() |> database.add_language(finnish) + + // Create translation context to choose active language + let ctx = Context(db, "fi") + + // "Morjens, maailma!" + io.println( + g_(ctx, "Hello, world!") + ) + + // "Ou jee, mulla on %s euroa" -- The correct plural form is chosen based on the amount provided + io.println( + n_(ctx, "Nice, I have %s euro", "Nice, I have %s euros", 15) + ) } ``` -Further documentation can be found at . +When you have annotated your code, but have no translations, the original messages will be returned. +Thus it's safe to add new strings to your program, the worst that can happen is that the original +strings will be shown. + +To start translating your program, first extract the strings from your source code. You should read +the documentation of the `xgettext` program to do this, but here is an example command that works for +Gleam source code, when you have imported the functions as `g_` and `n_`: + +```sh +xgettext src/**/*.gleam --keyword=g_:2 --keyword=n_:2,3 -o - --copyright-holder='Your Name' --package-name='Your App' --package-version='X.Y.Z' --msgid-bugs-address='email@example.com' +``` + +Store this output into a template file with the `.pot` extension and then use a translation app such as +[Poedit](https://poedit.net) to create language specific translation files. The output should be human +readable PO files that you can add to your version tracking if wanted, and binary MO files that Kielet +can read. + +### String replacement + +Note that Kielet does not do any string replacement. It's convention to use `%s` to denote the number +of items in a translatable string, but it's not enforced and has no special meaning. This means that +the translated message will contain the `%s` unchanged, and it is up to you to replace it with the +appropriate number, taking into account the target locale's number format. + +## Gettext limitations + +Due to how Gettext is built, the source language (the language used in your source files) can only be a +language with two plural forms. This means that there is a singular form, used when there is one item, +and a plural form that is used for every other amount of items. Unfortunately Gettext does not support +languages with more plural forms such as Arabic as the source language. They are supported as +translation targets, though. + +If you do not have any usage of `ngettext` in your code, your source language can be any language. This +may be a risky choice, though, since it's quite likely you will need plural forms in the future. ## Development ```sh -gleam run # Run the project gleam test # Run the tests -gleam shell # Run an Erlang shell ``` diff --git a/fi.mo b/fi.mo deleted file mode 100644 index 9958530..0000000 Binary files a/fi.mo and /dev/null differ diff --git a/fi.po b/fi.po deleted file mode 100644 index 56f94d0..0000000 --- a/fi.po +++ /dev/null @@ -1,34 +0,0 @@ -# SOME DESCRIPTIVE TITLE. -# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER -# This file is distributed under the same license as the PACKAGE package. -# FIRST AUTHOR , YEAR. -# -#, fuzzy -msgid "" -msgstr "" -"Project-Id-Version: \n" -"Report-Msgid-Bugs-To: mikko@ahlroth.fi\n" -"POT-Creation-Date: 2024-05-09 14:33+0300\n" -"PO-Revision-Date: 2024-05-09 14:36+0300\n" -"Last-Translator: \n" -"Language-Team: \n" -"Language: fi\n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=UTF-8\n" -"Content-Transfer-Encoding: 8bit\n" -"Plural-Forms: nplurals=2; plural=(n != 1);\n" -"X-Generator: Poedit 3.4.2\n" - -#: src/kielet/tester.gleam:6 -msgid "Press to activate" -msgstr "Paina aktivoidaksesi" - -#: src/kielet/tester.gleam:9 -#, c-format -msgid "Press to activate %s button" -msgid_plural "Press to activate %s buttons" -msgstr[0] "Paina aktivoidaksesi %s nappi" -msgstr[1] "Paina aktivoidaksesi %s nappia" - -#~ msgid "foo" -#~ msgstr "Juu" diff --git a/messages.po b/messages.po deleted file mode 100644 index 7aacf80..0000000 --- a/messages.po +++ /dev/null @@ -1,22 +0,0 @@ -# SOME DESCRIPTIVE TITLE. -# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER -# This file is distributed under the same license as the PACKAGE package. -# FIRST AUTHOR , YEAR. -# -#, fuzzy -msgid "" -msgstr "" -"Project-Id-Version: PACKAGE VERSION\n" -"Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-05-01 09:06+0300\n" -"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" -"Last-Translator: FULL NAME \n" -"Language-Team: LANGUAGE \n" -"Language: \n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=CHARSET\n" -"Content-Transfer-Encoding: 8bit\n" - -#: src/kielet/tester.gleam:5 -msgid "foo" -msgstr "" diff --git a/src/kielet.gleam b/src/kielet.gleam index aaf894e..ac60108 100644 --- a/src/kielet.gleam +++ b/src/kielet.gleam @@ -1,37 +1,25 @@ -import gleam/io -import gleam/result -import kielet/database.{type Database} -import kielet/plurals/parser -import kielet/plurals/tokenizer -import nibble +import kielet/context +import kielet/database -pub fn main() { - let _ = - "nplurals=2; plural=n != 1;" - |> tokenizer.tokenize() - |> result.unwrap([]) - |> nibble.run(parser.main()) - |> io.debug() - - "nplurals=6; - plural=n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 - : n%100>=11 ? 4 : 5;" - |> tokenizer.tokenize() - |> result.unwrap([]) - |> nibble.run(parser.main()) - |> io.debug() -} - -pub fn gettext(db: Database, msgid: String, language_code: String) -> String { - database.translate_singular(db, msgid, language_code) +/// Translate the given singular message. +pub fn gettext(context: context.Context, msgid: String) -> String { + database.translate_singular(context.database, msgid, context.language) } +/// Translate the given plural message. `n` is the amount of countable items +/// in the message. For example for the English language, from `"%s bunny"` and +/// `"%s bunnies"`, the latter would be returned when `n` is anything except 1. pub fn ngettext( - db: Database, + context: context.Context, singular: String, plural: String, - count: Int, - language_code: String, + n: Int, ) -> String { - database.translate_plural(db, singular, plural, count, language_code) + database.translate_plural( + context.database, + singular, + plural, + n, + context.language, + ) } diff --git a/src/kielet/context.gleam b/src/kielet/context.gleam new file mode 100644 index 0000000..2ed9ce5 --- /dev/null +++ b/src/kielet/context.gleam @@ -0,0 +1,7 @@ +import kielet/database + +/// Translation context, containing both the database and the active language +/// in one type for convenience. +pub type Context { + Context(database: database.Database, language: String) +} diff --git a/src/kielet/database.gleam b/src/kielet/database.gleam index 986f938..4c26651 100644 --- a/src/kielet/database.gleam +++ b/src/kielet/database.gleam @@ -1,3 +1,5 @@ +//// The database contains all the loaded languages. + import gleam/dict.{type Dict} import kielet/language.{type Language} @@ -5,14 +7,20 @@ pub opaque type Database { Database(languages: Dict(String, Language)) } +/// Create a new empty database. pub fn new() { Database(languages: dict.new()) } +/// Add a language to the database. pub fn add_language(db: Database, lang: Language) { Database(languages: dict.insert(db.languages, language.get_code(lang), lang)) } +/// Translate a singular message using the database. +/// +/// If the language is not found or does not have a translation for the message, +/// the message is returned as-is. pub fn translate_singular(db: Database, msgid: String, language_code: String) { case dict.get(db.languages, language_code) { Ok(lang) -> @@ -24,26 +32,31 @@ pub fn translate_singular(db: Database, msgid: String, language_code: String) { } } +/// Translate a plural message using the database. +/// +/// If the language is not found, does not have a translation for the message, +/// or does not have the correct plural for the given `n`, the given plural +/// message is returned. pub fn translate_plural( db: Database, msgid: String, plural: String, - count: Int, + n: Int, language_code: String, ) { - let translations = case dict.get(db.languages, language_code) { + case dict.get(db.languages, language_code) { Ok(lang) -> - case language.get_plural_translations(lang, msgid) { - Ok(translations) -> translations - Error(_) -> [msgid, plural] + case language.get_plural_translation(lang, msgid, n) { + Ok(translation) -> translation + Error(_) -> default_pick_plural(msgid, plural, n) } - Error(_) -> [msgid, plural] + Error(_) -> default_pick_plural(msgid, plural, n) } +} - let assert [singular, plural] = translations - - case count { - 1 -> singular +fn default_pick_plural(msgid: String, plural: String, n: Int) { + case n { + 1 -> msgid _ -> plural } } diff --git a/src/kielet/language.gleam b/src/kielet/language.gleam index ff795b2..3ce67cf 100644 --- a/src/kielet/language.gleam +++ b/src/kielet/language.gleam @@ -1,34 +1,64 @@ +//// A language contains translations and plural form information, and can be +//// used to translate messages. + import gleam/dict import gleam/result import kielet/mo +import kielet/plurals -pub type LanguageError { +/// Error returned when an MO file is loaded. +pub type LanguageLoadError { + /// The MO file could not be parsed. MoParseError(err: mo.ParseError) + + /// The plural forms in the MO file were invalid or missing. + PluralFormsLoadError(err: plurals.LoadError) } +/// Error when trying to translate a message. pub type TranslateError { + /// Tried to translate a plural message, but the translation is singular. MsgIsSingular(String) + + /// Tried to translate a singular message, but the translation is plural. MsgIsPlural(String) + + /// There was no translation for the message. MsgNotFound(String) + + /// The plural algorithm returned a form that is out of bounds for the amount + /// of plural forms. + PluralOutOfBounds(requested: Int, but_max_is: Int) } pub opaque type Language { - Language(code: String, translations: mo.Translations) + Language( + code: String, + translations: mo.Translations, + plurals: plurals.Plurals, + ) } +/// Load a language from the given MO file. pub fn load(code: String, mo_file: BitArray) { use mo <- result.try( mo.parse(mo_file) |> result.map_error(MoParseError), ) + use plurals <- result.try( + plurals.load_from_mo(mo) + |> result.map_error(PluralFormsLoadError), + ) - Ok(Language(code, mo.translations)) + Ok(Language(code, mo.translations, plurals)) } +/// Get the language's language code. pub fn get_code(lang: Language) { lang.code } +/// Translate a singular message. pub fn get_singular_translation(lang: Language, msgid: String) { case dict.get(lang.translations, msgid) { Ok(mostring) -> @@ -40,11 +70,22 @@ pub fn get_singular_translation(lang: Language, msgid: String) { } } -pub fn get_plural_translations(lang: Language, msgid: String) { +/// Translate a plural message. +pub fn get_plural_translation(lang: Language, msgid: String, n: Int) { case dict.get(lang.translations, msgid) { Ok(mostring) -> case mostring { - mo.Plural(content: c, ..) -> Ok(c) + mo.Plural(content: c, ..) -> { + let index = plurals.evaluate(lang.plurals, n) + case dict.get(c, index) { + Ok(msg) -> Ok(msg) + Error(_) -> + Error(PluralOutOfBounds( + requested: index, + but_max_is: dict.size(c) - 1, + )) + } + } _ -> Error(MsgIsSingular(msgid)) } _ -> Error(MsgNotFound(msgid)) diff --git a/src/kielet/mo.gleam b/src/kielet/mo.gleam index ef40360..4a38675 100644 --- a/src/kielet/mo.gleam +++ b/src/kielet/mo.gleam @@ -1,3 +1,9 @@ +//// An MO file parser. +//// +//// Both little and big endian files are supported. +//// +//// All strings must be UTF-8. + import gleam/bit_array import gleam/bool import gleam/dict.{type Dict} @@ -12,11 +18,14 @@ const eot = "" const nul = "\u{0}" +/// A translation in the file. The context is a free-form text that can be +/// added as extra context for the translators. pub type MoString { Singular(context: String, content: String) - Plural(context: String, content: List(String)) + Plural(context: String, content: Dict(Int, String)) } +/// Dictionary of translations keyed by the original msgid. pub type Translations = Dict(String, MoString) @@ -29,10 +38,18 @@ pub type ParseError { MagicNumberNotFound MalformedHeader UnknownRevision(Revision) + + /// An offset was given that either pointed directly out of bounds or the data + /// it pointed to would have been too big for the file. OffsetPastEnd(Int) MalformedOffsetTableEntry(BitArray) StringNotUTF8(BitArray) + + /// Metadata is contained as a "translation" for the msgid "" (empty string). + /// If this translation is missing, this error is returned. MetaItemMissing + + /// The metadata item is a plural. MetaItemIsNotSingular } @@ -43,14 +60,21 @@ pub type Revision { pub type Header { Header( revision: Revision, + /// Amount of strings in file. string_count: Int, + /// Offset to table of strings in original language. og_table_offset: Int, + /// Offset to translations table. trans_table_offset: Int, + /// Hash table size (not used by this parser). ht_size: Int, + /// Hash table offset. ht_offset: Int, ) } +/// An MO file can contain metadata that looks quite like HTTP headers. The most +/// important data here are the plural forms with the key `Plural-Forms`. pub type MetaData = Dict(String, String) @@ -63,6 +87,7 @@ pub type Mo { ) } +/// Parse given MO file data. pub fn parse(mo: BitArray) { use #(endianness, rest) <- result.try(parse_magic(mo)) use header <- result.try(parse_header(endianness, rest)) @@ -169,8 +194,11 @@ fn parse_translations(endianness: Endianness, header: Header, mo: BitArray) { let key = case og { Singular(content: c, ..) -> c - Plural(content: [c, ..], ..) -> c - Plural(..) -> panic as "Got plural form with zero entries" + Plural(content: c, ..) -> + case dict.get(c, 0) { + Ok(c) -> c + Error(_) -> panic as "Got plural form with zero entries" + } } Ok(dict.insert(translations, key, translation)) @@ -243,7 +271,13 @@ fn parse_mo_string(mo: BitArray, length: Int, offset: Int) { case string.split(str, nul) { [_] -> Ok(Singular(context: context, content: str)) - plurals -> Ok(Plural(context: context, content: plurals)) + plurals -> + Ok(Plural( + context: context, + content: plurals + |> list.index_map(fn(msg, i) { #(i, msg) }) + |> dict.from_list(), + )) } } diff --git a/src/kielet/plurals.gleam b/src/kielet/plurals.gleam index 40ebace..f5c6821 100644 --- a/src/kielet/plurals.gleam +++ b/src/kielet/plurals.gleam @@ -1,19 +1,44 @@ +//// Utilities for plural forms. + +import gleam/dict import gleam/result +import kielet/mo import kielet/plurals/ast +import kielet/plurals/evaluator import kielet/plurals/parser import kielet/plurals/syntax_error import kielet/plurals/tokenizer import nibble +const plural_forms_header = "Plural-Forms" + +/// Error returned when tokenizing or parsing the plural forms. pub type ParseError { TokenizerError(err: syntax_error.SyntaxError) ParserError(err: List(nibble.DeadEnd(tokenizer.Token, Nil))) } +/// Error returned when loading the plural forms from an MO file. +pub type LoadError { + ParsingFailed(err: ParseError) + NoPluralFormsHeader +} + pub type Plurals { Plurals(total: Int, algorithm: ast.Ast) } +/// Load plural forms from a parsed MO file. +pub fn load_from_mo(mo: mo.Mo) { + use plural_header <- result.try( + dict.get(mo.metadata, plural_forms_header) + |> result.replace_error(NoPluralFormsHeader), + ) + parse(plural_header) + |> result.map_error(ParsingFailed) +} + +/// Tokenize and parse given plural forms string. pub fn parse(input: String) { use tokens <- result.try( tokenizer.tokenize(input) @@ -25,3 +50,8 @@ pub fn parse(input: String) { ) Ok(Plurals(total: total, algorithm: ast)) } + +/// Evaluate given plurals, returning the index of the plural form to choose. +pub fn evaluate(plurals: Plurals, n: Int) { + evaluator.eval(plurals.algorithm, n) +} diff --git a/src/kielet/plurals/LICENSE.txt b/src/kielet/plurals/LICENSE.txt deleted file mode 100644 index 69bcec1..0000000 --- a/src/kielet/plurals/LICENSE.txt +++ /dev/null @@ -1,13 +0,0 @@ -Copyright 2015 Plataformatec Copyright 2020 Dashbit 2022 JOSHMARTIN GmbH - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. diff --git a/src/kielet/plurals/ast.gleam b/src/kielet/plurals/ast.gleam index a3858e5..299995e 100644 --- a/src/kielet/plurals/ast.gleam +++ b/src/kielet/plurals/ast.gleam @@ -1,3 +1,6 @@ +//// Plural forms abstract syntax tree + +/// A binary operation (operation with two arguments) pub type BinOp { Equal NotEqual @@ -11,9 +14,12 @@ pub type BinOp { } pub type Ast { + /// Represents the input value N + /// Integer literal Integer(Int) BinaryOperation(operator: BinOp, lvalue: Ast, rvalue: Ast) If(condition: Ast, truthy: Ast, falsy: Ast) + /// Parenthesised expression Paren(Ast) } diff --git a/src/kielet/plurals/evaluator.gleam b/src/kielet/plurals/evaluator.gleam index 3900b80..ee1f810 100644 --- a/src/kielet/plurals/evaluator.gleam +++ b/src/kielet/plurals/evaluator.gleam @@ -1,5 +1,11 @@ +//// Evaluation engine for the syntax tree + +// Translated from Expo evaluator + import kielet/plurals/ast +/// Evaluate the given syntax tree with the input, returning an integer value +/// representing the index of the plural form to choose. pub fn eval(ast: ast.Ast, input: Int) { case ast { ast.N -> input diff --git a/src/kielet/plurals/parser.gleam b/src/kielet/plurals/parser.gleam index dab3a0f..036d6b6 100644 --- a/src/kielet/plurals/parser.gleam +++ b/src/kielet/plurals/parser.gleam @@ -1,3 +1,7 @@ +//// Parser for the plural form token stream. +//// +//// The trailing semicolon in the format is optional. + import gleam/option import kielet/plurals/ast import kielet/plurals/tokenizer @@ -5,11 +9,12 @@ import nibble import nibble/lexer import nibble/pratt +/// Parse the given list of tokens into an abstract syntax tree. pub fn parse(input: List(lexer.Token(tokenizer.Token))) { - nibble.run(input, main()) + nibble.run(input, main_parser()) } -pub fn main() { +fn main_parser() { use _ <- nibble.do(nibble.token(tokenizer.NPlurals)) use _ <- nibble.do(nibble.token(tokenizer.Assignment)) use nplurals <- nibble.do(int_parser()) diff --git a/src/kielet/plurals/syntax_error.gleam b/src/kielet/plurals/syntax_error.gleam index e02f0e5..73c4ddf 100644 --- a/src/kielet/plurals/syntax_error.gleam +++ b/src/kielet/plurals/syntax_error.gleam @@ -1,5 +1,7 @@ import gleam/option +// Translated from Expo syntax error type + /// Error returned when the syntax in a plural forms string is invalid. pub type SyntaxError { SyntaxError(line: Int, column: option.Option(Int), reason: String) diff --git a/src/kielet/plurals/tokenizer.gleam b/src/kielet/plurals/tokenizer.gleam index 6635ed1..71c8092 100644 --- a/src/kielet/plurals/tokenizer.gleam +++ b/src/kielet/plurals/tokenizer.gleam @@ -1,3 +1,8 @@ +//// The tokenizer converts the plural forms syntax into a list of tokens for +//// later parsing. + +// Translated from Expo tokenizer + import gleam/int import gleam/list import gleam/option @@ -28,8 +33,9 @@ pub type Token { Int(value: Int) } -pub type TokenType - +/// Tokenize the given plural forms syntax. +/// +/// Whitespace is ignored and backslashes at the end of lines are removed. pub fn tokenize(str: String) { do_tokenize(string.to_graphemes(str), [], 1, 1) } diff --git a/src/kielet/tester.gleam b/src/kielet/tester.gleam deleted file mode 100644 index ec12921..0000000 --- a/src/kielet/tester.gleam +++ /dev/null @@ -1,44 +0,0 @@ -import gleam/io -import gleam/string -import kielet.{gettext as g_, ngettext as n_} -import kielet/database -import kielet/language -import simplifile - -pub fn main() { - let db = database.new() - let lang_code = "fi" - - let assert Ok(fi) = simplifile.read_bits("fi.mo") - let assert Ok(lang) = language.load(lang_code, fi) - let db = database.add_language(db, lang) - - print(db, "en") - print(db, "fi") -} - -fn print(db: database.Database, lang_code: String) { - io.debug(g_(db, "Press to activate", lang_code)) - io.debug(string.replace( - n_( - db, - "Press to activate %s button", - "Press to activate %s buttons", - 1, - lang_code, - ), - "%s", - "1", - )) - io.debug(string.replace( - n_( - db, - "Press to activate %s button", - "Press to activate %s buttons", - 5, - lang_code, - ), - "%s", - "5", - )) -} diff --git a/test/kielet_test.gleam b/test/kielet_test.gleam index 3831e7a..2693b04 100644 --- a/test/kielet_test.gleam +++ b/test/kielet_test.gleam @@ -1,12 +1,121 @@ import gleeunit import gleeunit/should +import kielet.{gettext as g_, ngettext as n_} +import kielet/context +import kielet/database +import kielet/language +import simplifile pub fn main() { gleeunit.main() } -// gleeunit test functions end in `_test` -pub fn hello_world_test() { - 1 - |> should.equal(1) +pub fn noop_singular_test() { + let context = context.Context(database: database.new(), language: "en") + should.equal(g_(context, "Hello, world!"), "Hello, world!") +} + +pub fn noop_plural_singular_test() { + let context = context.Context(database: database.new(), language: "en") + should.equal(n_(context, "%s person", "%s people", 1), "%s person") +} + +pub fn noop_plural_plural_test() { + let context = context.Context(database: database.new(), language: "en") + should.equal(n_(context, "%s person", "%s people", 2), "%s people") +} + +pub fn wrong_language_test() { + let ctx = load_languages() + should.equal( + g_( + ctx, + "Gleam is a friendly language for building type-safe systems that scale!", + ), + "Gleam is a friendly language for building type-safe systems that scale!", + ) +} + +pub fn fi_singular_test() { + let ctx = load_languages() + should.equal( + g_( + context.Context(..ctx, language: "fi"), + "Gleam is a friendly language for building type-safe systems that scale!", + ), + "Gleam on ystävällinen kieli skaalautuvien ja tyyppiturvallisten järjestelmien rakentamiseen!", + ) +} + +pub fn fi_plural_test() { + let ctx = load_languages() + let ctx = context.Context(..ctx, language: "fi") + should.equal( + n_(ctx, "Oh look, it's a duck", "Oh look, it's %s ducks", 1), + "Oho kato, ankka", + ) + + should.equal( + n_(ctx, "Oh look, it's a duck", "Oh look, it's %s ducks", 2), + "Oho kato, %s ankkaa", + ) +} + +pub fn uk_plural_test() { + let ctx = load_languages() + let ctx = context.Context(..ctx, language: "uk") + should.equal( + n_(ctx, "Oh look, it's a duck", "Oh look, it's %s ducks", 1), + "там %s качка", + ) + + should.equal( + n_(ctx, "Oh look, it's a duck", "Oh look, it's %s ducks", 2), + "там %s качки", + ) + + should.equal( + n_(ctx, "Oh look, it's a duck", "Oh look, it's %s ducks", 11), + "там %s качок", + ) + + should.equal( + n_(ctx, "Oh look, it's a duck", "Oh look, it's %s ducks", 12), + "там %s качок", + ) + + should.equal( + n_(ctx, "Oh look, it's a duck", "Oh look, it's %s ducks", 5), + "там %s качок", + ) + + should.equal( + n_(ctx, "Oh look, it's a duck", "Oh look, it's %s ducks", 21), + "там %s качка", + ) + + should.equal( + n_(ctx, "Oh look, it's a duck", "Oh look, it's %s ducks", 22), + "там %s качки", + ) + + should.equal( + n_(ctx, "Oh look, it's a duck", "Oh look, it's %s ducks", 25), + "там %s качок", + ) +} + +fn load_languages() { + database.new() + |> database.add_language(load_language("fi")) + |> database.add_language(load_language("uk")) + |> context.Context("en") +} + +fn load_language(lang_code: String) { + let mo_file = + "./test/locale/" <> lang_code <> "/LC_MESSAGES/" <> lang_code <> ".mo" + let assert Ok(mo_data) = simplifile.read_bits(mo_file) + let assert Ok(lang) = language.load(lang_code, mo_data) + lang } diff --git a/test/locale/fi/LC_MESSAGES/fi.mo b/test/locale/fi/LC_MESSAGES/fi.mo new file mode 100644 index 0000000..d03e970 Binary files /dev/null and b/test/locale/fi/LC_MESSAGES/fi.mo differ diff --git a/test/locale/fi/LC_MESSAGES/fi.po b/test/locale/fi/LC_MESSAGES/fi.po new file mode 100644 index 0000000..1f2e4ee --- /dev/null +++ b/test/locale/fi/LC_MESSAGES/fi.po @@ -0,0 +1,44 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR Mikko Ahlroth +# This file is distributed under the same license as the Kielet package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: Kielet 1.2.3\n" +"Report-Msgid-Bugs-To: mikko@ahlroth.fi\n" +"POT-Creation-Date: 2024-05-21 22:06+0300\n" +"PO-Revision-Date: 2024-05-21 22:09+0300\n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: fi\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Generator: Poedit 3.4.4\n" + +#: test/kielet_test.gleam:15 +msgid "Hello, world!" +msgstr "Morjens, maailma!" + +#: test/kielet_test.gleam:20 test/kielet_test.gleam:25 +#, c-format +msgid "%s person" +msgid_plural "%s people" +msgstr[0] "%s henkilö" +msgstr[1] "%s henkilöä" + +#: test/kielet_test.gleam:33 test/kielet_test.gleam:44 +#: test/kielet_test.gleam:55 +msgid "Gleam is a friendly language for building type-safe systems that scale!" +msgstr "Gleam on ystävällinen kieli skaalautuvien ja tyyppiturvallisten järjestelmien rakentamiseen!" + +# Muista “oho” +#: test/kielet_test.gleam:66 test/kielet_test.gleam:76 +#, c-format +msgid "Oh look, it's a duck" +msgid_plural "Oh look, it's %s ducks" +msgstr[0] "Oho kato, ankka" +msgstr[1] "Oho kato, %s ankkaa" diff --git a/messages.pot b/test/locale/messages.pot similarity index 54% rename from messages.pot rename to test/locale/messages.pot index 7655c26..533d198 100644 --- a/messages.pot +++ b/test/locale/messages.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: Kielet 1.2.3\n" "Report-Msgid-Bugs-To: mikko@ahlroth.fi\n" -"POT-Creation-Date: 2024-05-09 18:44+0300\n" +"POT-Creation-Date: 2024-05-21 22:06+0300\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,13 +18,25 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=INTEGER; plural=EXPRESSION;\n" -#: src/kielet/tester.gleam:15 -msgid "Press to activate" +#: test/kielet_test.gleam:15 +msgid "Hello, world!" msgstr "" -#: src/kielet/tester.gleam:18 +#: test/kielet_test.gleam:20 test/kielet_test.gleam:25 #, c-format -msgid "Press to activate %s button" -msgid_plural "Press to activate %s buttons" +msgid "%s person" +msgid_plural "%s people" +msgstr[0] "" +msgstr[1] "" + +#: test/kielet_test.gleam:33 test/kielet_test.gleam:44 +#: test/kielet_test.gleam:55 +msgid "Gleam is a friendly language for building type-safe systems that scale!" +msgstr "" + +#: test/kielet_test.gleam:66 test/kielet_test.gleam:76 +#, c-format +msgid "Oh look, it's a duck" +msgid_plural "Oh look, it's %s ducks" msgstr[0] "" msgstr[1] "" diff --git a/test/locale/uk/LC_MESSAGES/uk.mo b/test/locale/uk/LC_MESSAGES/uk.mo new file mode 100644 index 0000000..3c01f03 Binary files /dev/null and b/test/locale/uk/LC_MESSAGES/uk.mo differ diff --git a/test/locale/uk/LC_MESSAGES/uk.po b/test/locale/uk/LC_MESSAGES/uk.po new file mode 100644 index 0000000..6f364f0 --- /dev/null +++ b/test/locale/uk/LC_MESSAGES/uk.po @@ -0,0 +1,45 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR Mikko Ahlroth +# This file is distributed under the same license as the Kielet package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: Kielet 1.2.3\n" +"Report-Msgid-Bugs-To: mikko@ahlroth.fi\n" +"POT-Creation-Date: 2024-05-21 22:06+0300\n" +"PO-Revision-Date: 2024-05-21 22:13+0300\n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: uk\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<12 || n%100>14) ? 1 : 2);\n" +"X-Generator: Poedit 3.4.4\n" + +#: test/kielet_test.gleam:15 +msgid "Hello, world!" +msgstr "" + +#: test/kielet_test.gleam:20 test/kielet_test.gleam:25 +#, c-format +msgid "%s person" +msgid_plural "%s people" +msgstr[0] "" +msgstr[1] "" +msgstr[2] "" + +#: test/kielet_test.gleam:33 test/kielet_test.gleam:44 +#: test/kielet_test.gleam:55 +msgid "Gleam is a friendly language for building type-safe systems that scale!" +msgstr "" + +#: test/kielet_test.gleam:66 test/kielet_test.gleam:76 +#, c-format +msgid "Oh look, it's a duck" +msgid_plural "Oh look, it's %s ducks" +msgstr[0] "там %s качка" +msgstr[1] "там %s качки" +msgstr[2] "там %s качок" diff --git a/test/mo_test.gleam b/test/mo_test.gleam index c5998d5..6dd9753 100644 --- a/test/mo_test.gleam +++ b/test/mo_test.gleam @@ -1,8 +1,80 @@ +import gleam/dict import gleeunit/should import kielet/mo import simplifile -pub fn parse_test() { - let assert Ok(data) = simplifile.read_bits("./fi.mo") - should.be_ok(mo.parse(data)) +pub fn header_test() { + let mo = open() + should.equal(mo.endianness, mo.LittleEndian) + should.equal(mo.header.revision, mo.Revision(0, 0)) + should.equal(mo.header.string_count, 5) +} + +pub fn translations_test() { + let mo = open() + should.equal( + mo.translations, + dict.from_list([ + #( + "", + mo.Singular( + "", + "Project-Id-Version: Kielet 1.2.3\nReport-Msgid-Bugs-To: mikko@ahlroth.fi\nPO-Revision-Date: 2024-05-21 22:09+0300\nLast-Translator: \nLanguage-Team: \nLanguage: fi\nMIME-Version: 1.0\nContent-Type: text/plain; charset=UTF-8\nContent-Transfer-Encoding: 8bit\nPlural-Forms: nplurals=2; plural=(n != 1);\nX-Generator: Poedit 3.4.4\n", + ), + ), + #( + "%s person", + mo.Plural( + "", + dict.from_list([#(0, "%s henkilö"), #(1, "%s henkilöä")]), + ), + ), + #( + "Gleam is a friendly language for building type-safe systems that scale!", + mo.Singular( + "", + "Gleam on ystävällinen kieli skaalautuvien ja tyyppiturvallisten järjestelmien rakentamiseen!", + ), + ), + #("Hello, world!", mo.Singular("", "Morjens, maailma!")), + #( + "Oh look, it's a duck", + mo.Plural( + "", + dict.from_list([#(0, "Oho kato, ankka"), #(1, "Oho kato, %s ankkaa")]), + ), + ), + ]), + ) +} + +pub fn metadata_test() { + let mo = open() + should.equal( + mo.metadata, + dict.from_list([ + #("", ""), + #("Content-Transfer-Encoding", "8bit"), + #("Content-Type", "text/plain; charset=UTF-8"), + #("Language", "fi"), + #("Language-Team", ""), + #("Last-Translator", ""), + #("MIME-Version", "1.0"), + #("PO-Revision-Date", "2024-05-21 22:09+0300"), + #("Plural-Forms", "nplurals=2; plural=(n != 1);"), + #("Project-Id-Version", "Kielet 1.2.3"), + #("Report-Msgid-Bugs-To", "mikko@ahlroth.fi"), + #("X-Generator", "Poedit 3.4.4"), + ]), + ) +} + +fn open() { + let assert Ok(mo_data) = + simplifile.read_bits("./test/locale/fi/LC_MESSAGES/fi.mo") + + let mo = mo.parse(mo_data) + should.be_ok(mo) + let assert Ok(mo) = mo + mo }