1 /**
2 Internationalization/translation support for the web interface module.
3
4 Copyright: © 2014-2017 Sönke Ludwig
5 License: Subject to the terms of the MIT license, as written in the included LICENSE.txt file.
6 Authors: Sönke Ludwig
7 */
8 module vibe.web.i18n;
9
10 import vibe.http.server : HTTPServerRequest;
11
12 import std.algorithm : canFind, min, startsWith;
13 import std.range.primitives : ElementType, isForwardRange, save;
14 import std.range : only;
15
16 /**
17 Annotates an interface method or class with translation information.
18
19 The translation context contains information about supported languages
20 and the translated strings. Any translations will be automatically
21 applied to Diet templates, as well as strings passed to
22 $(D vibe.web.web.trWeb).
23
24 By default, the "Accept-Language" header of the incoming request will be
25 used to determine the language used. To override this behavior, add a
26 static method $(D determineLanguage) to the translation context, which
27 takes the request and returns a language string (see also the second
28 example).
29 */
30 @property TranslationContextAttribute!CONTEXT translationContext(CONTEXT)() { return TranslationContextAttribute!CONTEXT.init; }
31
32 ///
33 unittest {
34 import vibe.http.router : URLRouter;
35 import vibe.web.web : registerWebInterface;
36
37 struct TranslationContext {
38 import std.typetuple;
39 alias languages = TypeTuple!("en_US", "de_DE", "fr_FR");
40 //mixin translationModule!"app";
41 //mixin translationModule!"somelib";
42 }
43
44 @translationContext!TranslationContext
45 class MyWebInterface {
46 void getHome()
47 {
48 //render!("home.dt")
49 }
50 }
51
52 void test(URLRouter router)
53 {
54 router.registerWebInterface(new MyWebInterface);
55 }
56 }
57
58 /// Defining a custom function for determining the language.
59 unittest {
60 import vibe.http.router : URLRouter;
61 import vibe.http.server;
62 import vibe.web.web : registerWebInterface;
63
64 struct TranslationContext {
65 import std.typetuple;
66 // A language can be in the form en_US, en-US or en. Put the languages you want to prioritize first.
67 alias languages = TypeTuple!("en_US", "de_DE", "fr_FR");
68 //mixin translationModule!"app";
69 //mixin translationModule!"somelib";
70
71 // use language settings from the session instead of using the
72 // "Accept-Language" header
73 static string determineLanguage(scope HTTPServerRequest req)
74 {
75 if (!req.session) return req.determineLanguageByHeader(languages); // default behaviour using "Accept-Language" header
76 return req.session.get("language", "");
77 }
78 }
79
80 @translationContext!TranslationContext
81 class MyWebInterface {
82 void getHome()
83 {
84 //render!("home.dt")
85 }
86 }
87
88 void test(URLRouter router)
89 {
90 router.registerWebInterface(new MyWebInterface);
91 }
92 }
93
94 @safe unittest {
95 import vibe.http.router : URLRouter;
96 import vibe.http.server : HTTPServerRequest;
97 import vibe.web.web : registerWebInterface;
98
99 struct TranslationContext {
100 import std.typetuple;
101 alias languages = TypeTuple!("en_US", "de_DE", "fr_FR");
102 static string determineLanguage(scope HTTPServerRequest req) { return "en_US"; }
103 }
104
105 @translationContext!TranslationContext
106 class MyWebInterface { void getHome() @safe {} }
107
108 auto router = new URLRouter;
109 router.registerWebInterface(new MyWebInterface);
110 }
111
112
113 struct TranslationContextAttribute(CONTEXT) {
114 alias Context = CONTEXT;
115 }
116
117 /*
118 doctype 5
119 html
120 body
121 p& Hello, World!
122 p& This is a translated version of #{appname}.
123 html
124 p(class="Sasdasd")&.
125 This is a complete paragraph of translated text.
126 */
127
128 /** Makes a set of PO files available to a web interface class.
129
130 This mixin template needs to be mixed in at the class scope. It will parse all
131 translation files with the specified file name prefix and make their
132 translations available.
133
134 Params:
135 FILENAME = Base name of the set of PO files to mix in. A file with the
136 name `"<FILENAME>.<LANGUAGE>.po"` must be available as a string import
137 for each language defined in the translation context.
138
139 Bugs:
140 `FILENAME` should not contain (back)slash characters, as string imports
141 from sub directories will currently fail on Windows. See
142 $(LINK https://issues.dlang.org/show_bug.cgi?id=14349).
143
144 See_Also: `translationContext`
145 */
146 mixin template translationModule(string FILENAME)
147 {
148 import std.string : tr;
149 enum NAME = FILENAME.tr(`/.-\`, "____");
150 private static string file_mixins() {
151 string ret;
152 foreach (language; languages)
153 ret ~= "enum "~language~"_"~NAME~" = extractDeclStrings(import(`"~FILENAME~"."~language~".po`));\n";
154 return ret;
155 }
156
157 mixin(file_mixins);
158 }
159
160 template languageSeq(CTX) {
161 static if (is(typeof([CTX.languages]) : string[])) alias languageSeq = CTX.languages;
162 else alias languageSeq = aliasSeqOf!(CTX.languages);
163 }
164
165 /**
166 Performs the string translation for a statically given language.
167
168 The second overload takes a plural form and a number to select from a set
169 of translations based on the plural forms of the target language.
170 */
171 template tr(CTX, string LANG)
172 {
173 string tr(string key, string context = null)
174 {
175 return tr!(CTX, LANG)(key, null, 0, context);
176 }
177
178 string tr(string key, string key_plural, int n, string context = null)
179 {
180 static assert([languageSeq!CTX].canFind(LANG), "Unknown language: "~LANG);
181
182 foreach (i, mname; __traits(allMembers, CTX)) {
183 static if (mname.startsWith(LANG~"_")) {
184 enum langComponents = __traits(getMember, CTX, mname);
185 foreach (entry; langComponents.messages) {
186 if ((context is null) == (entry.context is null)) {
187 if (context is null || entry.context == context) {
188 if (entry.key == key) {
189 if (key_plural !is null) {
190 if (entry.pluralKey !is null && entry.pluralKey == key_plural) {
191 static if (langComponents.nplurals_expr !is null && langComponents.plural_func_expr !is null) {
192 mixin("int nplurals = "~langComponents.nplurals_expr~";");
193 if (nplurals > 0) {
194 mixin("int index = "~langComponents.plural_func_expr~";");
195 return entry.pluralValues[index];
196 }
197 return entry.value;
198 }
199 assert(false, "Plural translations are not supported when the po file does not contain an entry for Plural-Forms.");
200 }
201 } else {
202 return entry.value;
203 }
204 }
205 }
206 }
207 }
208 }
209 }
210
211 static if (is(typeof(CTX.enforceExistingKeys)) && CTX.enforceExistingKeys) {
212 if (key_plural !is null) {
213 if (context is null) {
214 assert(false, "Missing translation keys for "~LANG~": "~key~"&"~key_plural);
215 }
216 assert(false, "Missing translation key for "~LANG~"; "~context~": "~key~"&"~key_plural);
217 }
218
219 if (context is null) {
220 assert(false, "Missing translation key for "~LANG~": "~key);
221 }
222 assert(false, "Missing translation key for "~LANG~"; "~context~": "~key);
223 } else {
224 return n == 1 || !key_plural.length ? key : key_plural;
225 }
226 }
227 }
228
229 /// Determines a language code from the value of a header string.
230 /// Returns: The best match from the Accept-Language header for a language. `null` if there is no supported language.
231 public string determineLanguageByHeader(T)(string accept_language, T allowed_languages) @safe pure @nogc
232 if (isForwardRange!T && is(ElementType!T : string) || is(T == typeof(only())))
233 {
234 import std.algorithm : splitter, countUntil;
235 import std.string : indexOf;
236
237 // TODO: verify that allowed_languages doesn't contain a mix of languages with and without extra specifier for the same lanaguage (but only if one without specifier comes before those with specifier)
238 // Implementing that feature should try to give a compile time warning and not change the behaviour of this function.
239
240 if (!accept_language.length)
241 return null;
242
243 string fallback = null;
244 foreach (accept; accept_language.splitter(",")) {
245 auto sidx = accept.indexOf(';');
246 if (sidx >= 0)
247 accept = accept[0 .. sidx];
248
249 string alang, aextra;
250 auto asep = accept.countUntil!(a => a == '_' || a == '-');
251 if (asep < 0)
252 alang = accept;
253 else {
254 alang = accept[0 .. asep];
255 aextra = accept[asep + 1 .. $];
256 }
257
258 static if (!is(T == typeof(only()))) { // workaround for type errors
259 foreach (lang; allowed_languages.save) {
260 string lcode, lextra;
261 sidx = lang.countUntil!(a => a == '_' || a == '-');
262 if (sidx < 0)
263 lcode = lang;
264 else {
265 lcode = lang[0 .. sidx];
266 lextra = lang[sidx + 1 .. $];
267 }
268 // request en_US == serve en_US
269 if (lcode == alang && lextra == aextra)
270 return lang;
271 // request en_* == serve en
272 if (lcode == alang && !lextra.length)
273 return lang;
274 // request en == serve en_*
275 if (lcode == alang && !aextra.length)
276 return lang;
277 // request en* == serve en_* && be first occurence
278 if (lcode == alang && lextra.length && !fallback.length)
279 fallback = lang;
280 }
281 }
282 }
283
284 return fallback;
285 }
286
287 /// ditto
288 public string determineLanguageByHeader(Tuple...)(string accept_language, Tuple allowed_languages) @safe pure @nogc
289 if (Tuple.length != 1 || is(Tuple[0] : string))
290 {
291 return determineLanguageByHeader(accept_language, only(allowed_languages));
292 }
293
294 /// ditto
295 public string determineLanguageByHeader(T)(HTTPServerRequest req, T allowed_languages) @safe pure
296 if (isForwardRange!T && is(ElementType!T : string) || is(T == typeof(only())))
297 {
298 return determineLanguageByHeader(req.headers.get("Accept-Language", null), allowed_languages);
299 }
300
301 /// ditto
302 public string determineLanguageByHeader(Tuple...)(HTTPServerRequest req, Tuple allowed_languages) @safe pure
303 if (Tuple.length != 1 || is(Tuple[0] : string))
304 {
305 return determineLanguageByHeader(req.headers.get("Accept-Language", null), only(allowed_languages));
306 }
307
308 @safe unittest {
309 assert(determineLanguageByHeader("de-DE,de;q=0.8,en;q=0.6,en-US;q=0.4", ["en-US", "de_DE", "de_CH"]) == "de_DE");
310 assert(determineLanguageByHeader("de-CH,de;q=0.8,en;q=0.6,en-US;q=0.4", ["en_US", "de_DE", "de-CH"]) == "de-CH");
311 assert(determineLanguageByHeader("en_CA,en_US", ["ja_JP", "en"]) == "en");
312 assert(determineLanguageByHeader("en", ["ja_JP", "en"]) == "en");
313 assert(determineLanguageByHeader("en", ["ja_JP", "en_US"]) == "en_US");
314 assert(determineLanguageByHeader("en_US", ["ja-JP", "en"]) == "en");
315 assert(determineLanguageByHeader("de-DE,de;q=0.8,en;q=0.6,en-US;q=0.4", ["ja_JP"]) is null);
316 assert(determineLanguageByHeader("de, de-DE ;q=0.8 , en ;q=0.6 , en-US;q=0.4", ["de-DE"]) == "de-DE");
317 assert(determineLanguageByHeader("en_GB", ["en_US"]) == "en_US");
318 assert(determineLanguageByHeader("de_DE", ["en_US"]) is null);
319 assert(determineLanguageByHeader("en_US,enCA", ["en_GB"]) == "en_GB");
320 assert(determineLanguageByHeader("en_US,enCA", ["en_GB", "en"]) == "en");
321 assert(determineLanguageByHeader("en_US,enCA", ["en", "en_GB"]) == "en");
322 assert(determineLanguageByHeader("de,en_US;q=0.8,en;q=0.6", ["en_US", "de_DE"]) == "de_DE");
323 // TODO from above (should be invalid input having a more generic language first in the list!)
324 //assert(determineLanguageByHeader("en_US,enCA", ["en", "en_US"]) == "en_US");
325 }
326
327 package string determineLanguage(alias METHOD)(scope HTTPServerRequest req)
328 {
329 alias CTX = GetTranslationContext!METHOD;
330
331 static if (!is(CTX == void)) {
332 static if (is(typeof(CTX.determineLanguage(req)))) {
333 static assert(is(typeof(CTX.determineLanguage(req)) == string),
334 "determineLanguage in a translation context must return a language string.");
335 return CTX.determineLanguage(req);
336 } else {
337 return determineLanguageByHeader(req, only(CTX.languages));
338 }
339 } else return null;
340 }
341
342 unittest { // make sure that the custom determineLanguage is called
343 static struct CTX {
344 static string determineLanguage(Object a) { return "test"; }
345 }
346 @translationContext!CTX
347 static class Test {
348 void test()
349 {
350 }
351 }
352 auto test = new Test;
353 assert(determineLanguage!(test.test)(null) == "test");
354 }
355
356 unittest { // issue #1955
357 import std.meta : AliasSeq;
358 import vibe.inet.url : URL;
359 import vibe.http.server : createTestHTTPServerRequest;
360
361 static struct CTX {
362 alias languages = AliasSeq!();
363 }
364
365 @translationContext!CTX
366 class C {
367 void test() {}
368 }
369
370 auto req = createTestHTTPServerRequest(URL("http://127.0.0.1/test"));
371 assert(determineLanguage!(C.test)(req) == null);
372 }
373
374 package template GetTranslationContext(alias METHOD)
375 {
376 import vibe.internal.meta.uda;
377
378 alias PARENT = typeof(__traits(parent, METHOD).init);
379 enum FUNCTRANS = findFirstUDA!(TranslationContextAttribute, METHOD);
380 enum PARENTTRANS = findFirstUDA!(TranslationContextAttribute, PARENT);
381 static if (FUNCTRANS.found) alias GetTranslationContext = FUNCTRANS.value.Context;
382 else static if (PARENTTRANS.found) alias GetTranslationContext = PARENTTRANS.value.Context;
383 else alias GetTranslationContext = void;
384 }
385
386
387 private struct DeclString {
388 string context;
389 string key;
390 string pluralKey;
391 string value;
392 string[] pluralValues;
393 }
394
395 private struct LangComponents {
396 DeclString[] messages;
397 string nplurals_expr;
398 string plural_func_expr;
399 }
400
401 // Example po header
402 /*
403 * # Translation of kstars.po into Spanish.
404 * # This file is distributed under the same license as the kdeedu package.
405 * # Pablo de Vicente <pablo@foo.com>, 2005, 2006, 2007, 2008.
406 * # Eloy Cuadra <eloy@bar.net>, 2007, 2008.
407 * msgid ""
408 * msgstr ""
409 * "Project-Id-Version: kstars\n"
410 * "Report-Msgid-Bugs-To: http://bugs.kde.org\n"
411 * "POT-Creation-Date: 2008-09-01 09:37+0200\n"
412 * "PO-Revision-Date: 2008-07-22 18:13+0200\n"
413 * "Last-Translator: Eloy Cuadra <eloy@bar.net>\n"
414 * "Language-Team: Spanish <kde-l10n-es@kde.org>\n"
415 * "MIME-Version: 1.0\n"
416 * "Content-Type: text/plain; charset=UTF-8\n"
417 * "Content-Transfer-Encoding: 8bit\n"
418 * "Plural-Forms: nplurals=2; plural=n != 1;\n"
419 */
420
421 // PO format notes
422 /*
423 * # - Translator comment
424 * #: - source reference
425 * #. - extracted comments
426 * #, - flags such as "c-format" to indicate what king of substitutions may be present
427 * #| msgid - previous string comment
428 * #~ - obsolete message
429 * msgctxt - disabmbiguating context, like variable scope (optional, defaults to null)
430 * msgid - key to translate from (required)
431 * msgid_plural - plural form of the msg id (optional)
432 * msgstr - value to translate to (required)
433 * msgstr[0] - indexed translation for handling the various plural forms
434 * msgstr[1] - ditto
435 * msgstr[2] - ditto and etc...
436 */
437
438 LangComponents extractDeclStrings(string text)
439 {
440 DeclString[] declStrings;
441 string nplurals_expr;
442 string plural_func_expr;
443
444 size_t i = 0;
445 while (true) {
446 i = skipToDirective(i, text);
447 if (i >= text.length) break;
448
449 string context = null;
450
451 // msgctxt is an optional field
452 if (text.length - i >= 7 && text[i .. i+7] == "msgctxt") {
453 i = skipWhitespace(i+7, text);
454
455 auto icntxt = skipString(i, text);
456 context = dstringUnescape(wrapText(text[i+1 .. icntxt-1]));
457 i = skipToDirective(icntxt, text);
458 }
459
460 // msgid is a required field
461 assert(text.length - i >= 5 && text[i .. i+5] == "msgid", "Expected 'msgid', got '"~text[i .. min(i+10, $)]~"'.");
462 i += 5;
463
464 i = skipWhitespace(i, text);
465
466 auto iknext = skipString(i, text);
467 auto key = dstringUnescape(wrapText(text[i+1 .. iknext-1]));
468 i = iknext;
469
470 i = skipToDirective(i, text);
471
472 // msgid_plural is an optional field
473 string key_plural = null;
474 if (text.length - i >= 12 && text[i .. i+12] == "msgid_plural") {
475 i = skipWhitespace(i+12, text);
476 auto iprl = skipString(i, text);
477 key_plural = dstringUnescape(wrapText(text[i+1 .. iprl-1]));
478 i = skipToDirective(iprl, text);
479 }
480
481 // msgstr is a required field
482 assert(text.length - i >= 6 && text[i .. i+6] == "msgstr", "Expected 'msgstr', got '"~text[i .. min(i+10, $)]~"'.");
483 i += 6;
484
485 i = skipWhitespace(i, text);
486 auto ivnext = skipString(i, text);
487 auto value = dstringUnescape(wrapText(text[i+1 .. ivnext-1]));
488 i = ivnext;
489 i = skipToDirective(i, text);
490
491 // msgstr[n] is a required field when msgid_plural is not null, and ignored otherwise
492 string[] value_plural;
493 if (key_plural !is null) {
494 while (text.length - i >= 6 && text[i .. i+6] == "msgstr") {
495 i = skipIndex(i+6, text);
496 i = skipWhitespace(i, text);
497 auto ims = skipString(i, text);
498
499 string plural = dstringUnescape(wrapText(text[i+1 .. ims-1]));
500 i = skipLine(ims, text);
501
502 // Is it safe to assume that the entries are always sequential?
503 value_plural ~= plural;
504 }
505 }
506
507 // Add the translation for the current language
508 if (key == "") {
509 nplurals_expr = parse_nplurals(value);
510 plural_func_expr = parse_plural_expression(value);
511 }
512
513 declStrings ~= DeclString(context, key, key_plural, value, value_plural);
514 }
515
516 return LangComponents(declStrings, nplurals_expr, plural_func_expr);
517 }
518
519 // Verify that two simple messages can be read and parsed correctly
520 unittest {
521 auto str = `
522 # first string
523 msgid "ordinal.1"
524 msgstr "first"
525
526 # second string
527 msgid "ordinal.2"
528 msgstr "second"`;
529
530 auto components = extractDeclStrings(str);
531 auto ds = components.messages;
532 assert(2 == ds.length, "Not enough DeclStrings have been processed");
533 assert(ds[0].key == "ordinal.1", "The first key is not right.");
534 assert(ds[0].value == "first", "The first value is not right.");
535 assert(ds[1].key == "ordinal.2", "The second key is not right.");
536 assert(ds[1].value == "second", "The second value is not right.");
537 }
538
539 // Verify that the fields cannot be defined out of order
540 unittest {
541 import core.exception : AssertError;
542 import std.exception : assertThrown;
543
544 auto str1 = `
545 # unexpected field ahead
546 msgstr "world"
547 msgid "hello"`;
548
549 assertThrown!AssertError(extractDeclStrings(str1));
550 }
551
552 // Verify that string wrapping is handled correctly
553 unittest {
554 auto str = `
555 # The following text is wrapped
556 msgid ""
557 "This is an example of text that "
558 "has been wrapped on two lines."
559 msgstr ""
560 "It should not matter where it takes place, "
561 "the strings should all be concatenated properly."`;
562
563 auto ds = extractDeclStrings(str).messages;
564 assert(1 == ds.length, "Expected one DeclString to have been processed.");
565 assert(ds[0].key == "This is an example of text that has been wrapped on two lines.", "Failed to properly wrap the key");
566 assert(ds[0].value == "It should not matter where it takes place, the strings should all be concatenated properly.", "Failed to properly wrap the key");
567 }
568
569 // Verify that string wrapping and unescaping is handled correctly on example of PO headers
570 unittest {
571 auto str = `
572 # English translations for ThermoWebUI package.
573 # This file is put in the public domain.
574 # Automatically generated, 2015.
575 #
576 msgid ""
577 msgstr ""
578 "Project-Id-Version: PROJECT VERSION\n"
579 "Report-Msgid-Bugs-To: developer@example.com\n"
580 "POT-Creation-Date: 2015-04-13 17:55+0600\n"
581 "PO-Revision-Date: 2015-04-13 14:13+0600\n"
582 "Last-Translator: Automatically generated\n"
583 "Language-Team: none\n"
584 "Language: en\n"
585 "MIME-Version: 1.0\n"
586 "Content-Type: text/plain; charset=UTF-8\n"
587 "Content-Transfer-Encoding: 8bit\n"
588 "Plural-Forms: nplurals=2; plural=(n != 1);\n"
589 `;
590 auto expected = `Project-Id-Version: PROJECT VERSION
591 Report-Msgid-Bugs-To: developer@example.com
592 POT-Creation-Date: 2015-04-13 17:55+0600
593 PO-Revision-Date: 2015-04-13 14:13+0600
594 Last-Translator: Automatically generated
595 Language-Team: none
596 Language: en
597 MIME-Version: 1.0
598 Content-Type: text/plain; charset=UTF-8
599 Content-Transfer-Encoding: 8bit
600 Plural-Forms: nplurals=2; plural=(n != 1);
601 `;
602
603 auto ds = extractDeclStrings(str).messages;
604 assert(1 == ds.length, "Expected one DeclString to have been processed.");
605 assert(ds[0].key == "", "Failed to properly wrap or unescape the key");
606 assert(ds[0].value == expected, "Failed to properly wrap or unescape the value");
607 }
608
609 // Verify that the message context is properly parsed
610 unittest {
611 auto str1 = `
612 # "C" is for cookie
613 msgctxt "food"
614 msgid "C"
615 msgstr "C is for cookie, that's good enough for me."`;
616
617 auto ds1 = extractDeclStrings(str1).messages;
618 assert(1 == ds1.length, "Expected one DeclString to have been processed.");
619 assert(ds1[0].context == "food", "Expected a context of food");
620 assert(ds1[0].key == "C", "Expected to find the letter C for the msgid.");
621 assert(ds1[0].value == "C is for cookie, that's good enough for me.", "Unexpected value encountered for the msgstr.");
622
623 auto str2 = `
624 # No context validation
625 msgid "alpha"
626 msgstr "First greek letter."`;
627
628 auto ds2 = extractDeclStrings(str2).messages;
629 assert(1 == ds2.length, "Expected one DeclString to have been processed.");
630 assert(ds2[0].context is null, "Expected the context to be null when it is not defined.");
631 }
632
633 unittest {
634 enum str = `
635 # "C" is for cookie
636 msgctxt "food"
637 msgid "C"
638 msgstr "C is for cookie, that's good enough for me."
639
640 # "C" is for language
641 msgctxt "lang"
642 msgid "C"
643 msgstr "Catalan"
644
645 # Just "C"
646 msgid "C"
647 msgstr "Third letter"
648 `;
649
650 enum components = extractDeclStrings(str);
651
652 struct TranslationContext {
653 import std.typetuple;
654 enum enforceExistingKeys = true;
655 alias languages = TypeTuple!("en_US");
656
657 // Note that this is normally handled by mixing in an external file.
658 enum en_US_unittest = components;
659 }
660
661 auto newTr(string msgid, string msgcntxt = null) {
662 return tr!(TranslationContext, "en_US")(msgid, msgcntxt);
663 }
664
665 assert(newTr("C", "food") == "C is for cookie, that's good enough for me.", "Unexpected translation based on context.");
666 assert(newTr("C", "lang") == "Catalan", "Unexpected translation based on context.");
667 assert(newTr("C") == "Third letter", "Unexpected translation based on context.");
668 }
669
670 unittest {
671 enum str = `msgid ""
672 msgstr ""
673 "Project-Id-Version: kstars\\n"
674 "Plural-Forms: nplurals=2; plural=n != 1;\\n"
675
676 msgid "One file was deleted."
677 msgid_plural "Files were deleted."
678 msgstr "One file was deleted."
679 msgstr[0] "1 file was deleted."
680 msgstr[1] "%d files were deleted."
681
682 msgid "One file was created."
683 msgid_plural "Several files were created."
684 msgstr "One file was created."
685 msgstr[0] "1 file was created"
686 msgstr[1] "%d files were created."
687 `;
688
689 import std.stdio;
690 enum components = extractDeclStrings(str);
691
692 struct TranslationContext {
693 import std.typetuple;
694 enum enforceExistingKeys = true;
695 alias languages = TypeTuple!("en_US");
696
697 // Note that this is normally handled by mixing in an external file.
698 enum en_US_unittest2 = components;
699 }
700
701 auto newTr(string msgid, string msgid_plural, int count, string msgcntxt = null) {
702 return tr!(TranslationContext, "en_US")(msgid, msgid_plural, count, msgcntxt);
703 }
704
705 string expected = "1 file was deleted.";
706 auto actual = newTr("One file was deleted.", "Files were deleted.", 1);
707 assert(expected == actual, "Expected: '"~expected~"' but got '"~actual~"'");
708
709 expected = "%d files were deleted.";
710 actual = newTr("One file was deleted.", "Files were deleted.", 42);
711 assert(expected == actual, "Expected: '"~expected~"' but got '"~actual~"'");
712 }
713
714 private size_t skipToDirective(size_t i, ref string text)
715 {
716 while (i < text.length) {
717 i = skipWhitespace(i, text);
718 if (i < text.length && text[i] == '#') i = skipLine(i, text);
719 else break;
720 }
721 return i;
722 }
723
724 private size_t skipWhitespace(size_t i, ref string text)
725 {
726 while (i < text.length && (text[i] == ' ' || text[i] == '\t' || text[i] == '\n' || text[i] == '\r'))
727 i++;
728 return i;
729 }
730
731 private size_t skipLine(size_t i, ref string text)
732 {
733 while (i < text.length && text[i] != '\r' && text[i] != '\n') i++;
734 if (i+1 < text.length && (text[i+1] == '\r' || text[i+1] == '\n') && text[i] != text[i+1]) i++;
735 return i+1;
736 }
737
738 private size_t skipString(size_t i, ref string text)
739 {
740 import std.conv : to;
741 assert(text[i] == '"', "Expected to encounter the start of a string at position: "~to!string(i));
742 i++;
743 while (true) {
744 assert(i < text.length, "Missing closing '\"' for string: "~text[i .. min($, 10)]);
745 if (text[i] == '"') {
746 if (i+1 < text.length) {
747 auto j = skipWhitespace(i+1, text);
748 if (j<text.length && text[j] == '"') return skipString(j, text);
749 }
750 return i+1;
751 }
752 if (text[i] == '\\') i += 2;
753 else i++;
754 }
755 }
756
757 private size_t skipIndex(size_t i, ref string text) {
758 import std.conv : to;
759 assert(text[i] == '[', "Expected to encounter a plural form of msgstr at position: "~to!string(i));
760 for (; i<text.length; ++i) {
761 if (text[i] == ']') {
762 return i+1;
763 }
764 }
765 assert(false, "Missing a ']' for a msgstr in a translation file.");
766 }
767
768 private string wrapText(string str)
769 {
770 string ret;
771 bool wrapped = false;
772
773 for (size_t i=0; i<str.length; ++i) {
774 if (str[i] == '\\') {
775 assert(i+1 < str.length, "The string ends with the escape char: " ~ str);
776 ret ~= str[i..i+2];
777 ++i;
778 } else if (str[i] == '"') {
779 wrapped = true;
780 size_t j = skipWhitespace(i+1, str);
781 if (j < str.length && str[j] == '"') {
782 i=j;
783 }
784 } else ret ~= str[i];
785 }
786
787 if (wrapped) return ret;
788 return str;
789 }
790
791 private string parse_nplurals(string msgstr)
792 in { assert(msgstr, "An empty string cannot be parsed for Plural-Forms."); }
793 do {
794 import std.string : indexOf, CaseSensitive;
795
796 auto start = msgstr.indexOf("Plural-Forms:", CaseSensitive.no);
797 if (start > -1) {
798 auto beg = msgstr.indexOf("nplurals=", start+13, CaseSensitive.no);
799 if (beg > -1) {
800 auto end = msgstr.indexOf(';', beg+9, CaseSensitive.no);
801 if (end > -1) {
802 return msgstr[beg+9 .. end];
803 }
804 return msgstr[beg+9 .. $];
805 }
806 }
807
808 return null;
809 }
810
811 unittest {
812 auto res = parse_nplurals("Plural-Forms: nplurals=2; plural=n != 1;\n");
813 assert(res == "2", "Failed to parse the correct number of plural forms for a language.");
814 }
815
816 private string parse_plural_expression(string msgstr)
817 in { assert(msgstr, "An empty string cannot be parsed for Plural-Forms."); }
818 do {
819 import std.string : indexOf, CaseSensitive;
820
821 auto start = msgstr.indexOf("Plural-Forms:", CaseSensitive.no);
822 if (start > -1) {
823 auto beg = msgstr.indexOf("plural=", start+13, CaseSensitive.no);
824 if (beg > -1) {
825 auto end = msgstr.indexOf(';', beg+7, CaseSensitive.no);
826 if (end > -1) {
827 return msgstr[beg+7 .. end];
828 }
829 return msgstr[beg+7 .. $];
830 }
831 }
832
833 return null;
834 }
835
836 unittest {
837 auto res = parse_plural_expression("Plural-Forms: nplurals=2; plural=n != 1;\n");
838 assert(res == "n != 1", "Failed to parse the plural expression for a language.");
839 }
840
841 private string dstringUnescape(in string str)
842 {
843 string ret;
844 size_t i, start = 0;
845 for( i = 0; i < str.length; i++ )
846 if( str[i] == '\\' ){
847 if( i > start ){
848 if( start > 0 ) ret ~= str[start .. i];
849 else ret = str[0 .. i];
850 }
851 assert(i+1 < str.length, "The string ends with the escape char: " ~ str);
852 switch(str[i+1]){
853 default: ret ~= str[i+1]; break;
854 case 'r': ret ~= '\r'; break;
855 case 'n': ret ~= '\n'; break;
856 case 't': ret ~= '\t'; break;
857 }
858 i++;
859 start = i+1;
860 }
861
862 if( i > start ){
863 if( start == 0 ) return str;
864 else ret ~= str[start .. i];
865 }
866 return ret;
867 }