1 /** 2 Internationalization/translation support for the web interface module. 3 4 Copyright: © 2014-2015 RejectedSoftware e.K. 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 : isForwardRange; 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 mixin template file_mixin(size_t i) { 151 static if (i < languages.length) { 152 enum components = extractDeclStrings(import(FILENAME~"."~languages[i]~".po")); 153 mixin("enum "~languages[i]~"_"~NAME~" = components;"); 154 //mixin decls_mixin!(languages[i], 0); 155 mixin file_mixin!(i+1); 156 } 157 } 158 159 mixin file_mixin!0; 160 } 161 162 /** 163 Performs the string translation for a statically given language. 164 165 The second overload takes a plural form and a number to select from a set 166 of translations based on the plural forms of the target language. 167 */ 168 template tr(CTX, string LANG) 169 { 170 string tr(string key, string context = null) 171 { 172 return tr!(CTX, LANG)(key, null, 0, context); 173 } 174 175 string tr(string key, string key_plural, int n, string context = null) 176 { 177 static assert([CTX.languages].canFind(LANG), "Unknown language: "~LANG); 178 179 foreach (i, mname; __traits(allMembers, CTX)) { 180 static if (mname.startsWith(LANG~"_")) { 181 enum langComponents = __traits(getMember, CTX, mname); 182 foreach (entry; langComponents.messages) { 183 if ((context is null) == (entry.context is null)) { 184 if (context is null || entry.context == context) { 185 if (entry.key == key) { 186 if (key_plural !is null) { 187 if (entry.pluralKey !is null && entry.pluralKey == key_plural) { 188 static if (langComponents.nplurals_expr !is null && langComponents.plural_func_expr !is null) { 189 mixin("int nplurals = "~langComponents.nplurals_expr~";"); 190 if (nplurals > 0) { 191 mixin("int index = "~langComponents.plural_func_expr~";"); 192 return entry.pluralValues[index]; 193 } 194 return entry.value; 195 } 196 assert(false, "Plural translations are not supported when the po file does not contain an entry for Plural-Forms."); 197 } 198 } else { 199 return entry.value; 200 } 201 } 202 } 203 } 204 } 205 } 206 } 207 208 static if (is(typeof(CTX.enforceExistingKeys)) && CTX.enforceExistingKeys) { 209 if (key_plural !is null) { 210 if (context is null) { 211 assert(false, "Missing translation keys for "~LANG~": "~key~"&"~key_plural); 212 } 213 assert(false, "Missing translation key for "~LANG~"; "~context~": "~key~"&"~key_plural); 214 } 215 216 if (context is null) { 217 assert(false, "Missing translation key for "~LANG~": "~key); 218 } 219 assert(false, "Missing translation key for "~LANG~"; "~context~": "~key); 220 } else { 221 return n == 1 || !key_plural.length ? key : key_plural; 222 } 223 } 224 } 225 226 /// Determines a language code from the value of a header string. 227 /// Returns: The best match from the Accept-Language header for a language. `null` if there is no supported language. 228 public string determineLanguageByHeader(T)(string accept_language, T allowed_languages) @safe pure @nogc 229 if (isForwardRange!T) 230 { 231 import std.algorithm : splitter, countUntil; 232 import std.string : indexOf; 233 234 // 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) 235 // Implementing that feature should try to give a compile time warning and not change the behaviour of this function. 236 237 if (!accept_language.length) 238 return null; 239 240 string fallback = null; 241 foreach (accept; accept_language.splitter(",")) { 242 auto sidx = accept.indexOf(';'); 243 if (sidx >= 0) 244 accept = accept[0 .. sidx]; 245 246 string alang, aextra; 247 auto asep = accept.countUntil!(a => a == '_' || a == '-'); 248 if (asep < 0) 249 alang = accept; 250 else { 251 alang = accept[0 .. asep]; 252 aextra = accept[asep + 1 .. $]; 253 } 254 255 foreach (lang; allowed_languages) { 256 string lcode, lextra; 257 sidx = lang.countUntil!(a => a == '_' || a == '-'); 258 if (sidx < 0) 259 lcode = lang; 260 else { 261 lcode = lang[0 .. sidx]; 262 lextra = lang[sidx + 1 .. $]; 263 } 264 // request en_US == serve en_US 265 if (lcode == alang && lextra == aextra) 266 return lang; 267 // request en_* == serve en 268 if (lcode == alang && !lextra.length) 269 return lang; 270 // request en* == serve en_* && be first occurence 271 if (lcode == alang && lextra.length && !fallback.length) 272 fallback = lang; 273 } 274 } 275 276 return fallback; 277 } 278 279 /// ditto 280 public string determineLanguageByHeader(Tuple...)(string accept_language, Tuple allowed_languages) @safe pure @nogc 281 { 282 return determineLanguageByHeader(accept_language, only(allowed_languages)); 283 } 284 285 /// ditto 286 public string determineLanguageByHeader(T)(HTTPServerRequest req, T allowed_languages) @safe pure 287 if (isForwardRange!T) 288 { 289 return determineLanguageByHeader(req.headers.get("Accept-Language", null), allowed_languages); 290 } 291 292 /// ditto 293 public string determineLanguageByHeader(Tuple...)(HTTPServerRequest req, Tuple allowed_languages) @safe pure 294 { 295 return determineLanguageByHeader(req.headers.get("Accept-Language", null), only(allowed_languages)); 296 } 297 298 @safe unittest { 299 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"); 300 assert(determineLanguageByHeader("de,de-CH;q=0.8,en;q=0.6,en-US;q=0.4", ["en_US", "de_DE", "de-CH"]) == "de-CH"); 301 assert(determineLanguageByHeader("en_CA,en_US", ["ja_JP", "en"]) == "en"); 302 assert(determineLanguageByHeader("en", ["ja_JP", "en"]) == "en"); 303 assert(determineLanguageByHeader("en", ["ja_JP", "en_US"]) == "en_US"); 304 assert(determineLanguageByHeader("en_US", ["ja-JP", "en"]) == "en"); 305 assert(determineLanguageByHeader("de,de-DE;q=0.8,en;q=0.6,en-US;q=0.4", ["ja_JP"]) is null); 306 assert(determineLanguageByHeader("de, de-DE ;q=0.8 , en ;q=0.6 , en-US;q=0.4", ["de-DE"]) == "de-DE"); 307 assert(determineLanguageByHeader("en_GB", ["en_US"]) == "en_US"); 308 assert(determineLanguageByHeader("de_DE", ["en_US"]) is null); 309 assert(determineLanguageByHeader("en_US,enCA", ["en_GB"]) == "en_GB"); 310 assert(determineLanguageByHeader("en_US,enCA", ["en_GB", "en"]) == "en"); 311 assert(determineLanguageByHeader("en_US,enCA", ["en", "en_GB"]) == "en"); 312 // TODO from above (should be invalid input having a more generic language first in the list!) 313 //assert(determineLanguageByHeader("en_US,enCA", ["en", "en_US"]) == "en_US"); 314 } 315 316 package string determineLanguage(alias METHOD)(scope HTTPServerRequest req) 317 { 318 alias CTX = GetTranslationContext!METHOD; 319 320 static if (!is(CTX == void)) { 321 static if (is(typeof(CTX.determineLanguage(req)))) { 322 static assert(is(typeof(CTX.determineLanguage(req)) == string), 323 "determineLanguage in a translation context must return a language string."); 324 return CTX.determineLanguage(req); 325 } else { 326 return determineLanguageByHeader(req, CTX.languages); 327 } 328 } else return null; 329 } 330 331 unittest { // make sure that the custom determineLanguage is called 332 static struct CTX { 333 static string determineLanguage(Object a) { return "test"; } 334 } 335 @translationContext!CTX 336 static class Test { 337 void test() 338 { 339 } 340 } 341 auto test = new Test; 342 assert(determineLanguage!(test.test)(null) == "test"); 343 } 344 345 package template GetTranslationContext(alias METHOD) 346 { 347 import vibe.internal.meta.uda; 348 349 alias PARENT = typeof(__traits(parent, METHOD).init); 350 enum FUNCTRANS = findFirstUDA!(TranslationContextAttribute, METHOD); 351 enum PARENTTRANS = findFirstUDA!(TranslationContextAttribute, PARENT); 352 static if (FUNCTRANS.found) alias GetTranslationContext = FUNCTRANS.value.Context; 353 else static if (PARENTTRANS.found) alias GetTranslationContext = PARENTTRANS.value.Context; 354 else alias GetTranslationContext = void; 355 } 356 357 358 private struct DeclString { 359 string context; 360 string key; 361 string pluralKey; 362 string value; 363 string[] pluralValues; 364 } 365 366 private struct LangComponents { 367 DeclString[] messages; 368 string nplurals_expr; 369 string plural_func_expr; 370 } 371 372 // Example po header 373 /* 374 * # Translation of kstars.po into Spanish. 375 * # This file is distributed under the same license as the kdeedu package. 376 * # Pablo de Vicente <pablo@foo.com>, 2005, 2006, 2007, 2008. 377 * # Eloy Cuadra <eloy@bar.net>, 2007, 2008. 378 * msgid "" 379 * msgstr "" 380 * "Project-Id-Version: kstars\n" 381 * "Report-Msgid-Bugs-To: http://bugs.kde.org\n" 382 * "POT-Creation-Date: 2008-09-01 09:37+0200\n" 383 * "PO-Revision-Date: 2008-07-22 18:13+0200\n" 384 * "Last-Translator: Eloy Cuadra <eloy@bar.net>\n" 385 * "Language-Team: Spanish <kde-l10n-es@kde.org>\n" 386 * "MIME-Version: 1.0\n" 387 * "Content-Type: text/plain; charset=UTF-8\n" 388 * "Content-Transfer-Encoding: 8bit\n" 389 * "Plural-Forms: nplurals=2; plural=n != 1;\n" 390 */ 391 392 // PO format notes 393 /* 394 * # - Translator comment 395 * #: - source reference 396 * #. - extracted comments 397 * #, - flags such as "c-format" to indicate what king of substitutions may be present 398 * #| msgid - previous string comment 399 * #~ - obsolete message 400 * msgctxt - disabmbiguating context, like variable scope (optional, defaults to null) 401 * msgid - key to translate from (required) 402 * msgid_plural - plural form of the msg id (optional) 403 * msgstr - value to translate to (required) 404 * msgstr[0] - indexed translation for handling the various plural forms 405 * msgstr[1] - ditto 406 * msgstr[2] - ditto and etc... 407 */ 408 409 LangComponents extractDeclStrings(string text) 410 { 411 DeclString[] declStrings; 412 string nplurals_expr; 413 string plural_func_expr; 414 415 size_t i = 0; 416 while (true) { 417 i = skipToDirective(i, text); 418 if (i >= text.length) break; 419 420 string context = null; 421 422 // msgctxt is an optional field 423 if (text.length - i >= 7 && text[i .. i+7] == "msgctxt") { 424 i = skipWhitespace(i+7, text); 425 426 auto icntxt = skipString(i, text); 427 context = dstringUnescape(wrapText(text[i+1 .. icntxt-1])); 428 i = skipToDirective(icntxt, text); 429 } 430 431 // msgid is a required field 432 assert(text.length - i >= 5 && text[i .. i+5] == "msgid", "Expected 'msgid', got '"~text[i .. min(i+10, $)]~"'."); 433 i += 5; 434 435 i = skipWhitespace(i, text); 436 437 auto iknext = skipString(i, text); 438 auto key = dstringUnescape(wrapText(text[i+1 .. iknext-1])); 439 i = iknext; 440 441 i = skipToDirective(i, text); 442 443 // msgid_plural is an optional field 444 string key_plural = null; 445 if (text.length - i >= 12 && text[i .. i+12] == "msgid_plural") { 446 i = skipWhitespace(i+12, text); 447 auto iprl = skipString(i, text); 448 key_plural = dstringUnescape(wrapText(text[i+1 .. iprl-1])); 449 i = skipToDirective(iprl, text); 450 } 451 452 // msgstr is a required field 453 assert(text.length - i >= 6 && text[i .. i+6] == "msgstr", "Expected 'msgstr', got '"~text[i .. min(i+10, $)]~"'."); 454 i += 6; 455 456 i = skipWhitespace(i, text); 457 auto ivnext = skipString(i, text); 458 auto value = dstringUnescape(wrapText(text[i+1 .. ivnext-1])); 459 i = ivnext; 460 i = skipToDirective(i, text); 461 462 // msgstr[n] is a required field when msgid_plural is not null, and ignored otherwise 463 string[] value_plural; 464 if (key_plural !is null) { 465 while (text.length - i >= 6 && text[i .. i+6] == "msgstr") { 466 i = skipIndex(i+6, text); 467 i = skipWhitespace(i, text); 468 auto ims = skipString(i, text); 469 470 string plural = dstringUnescape(wrapText(text[i+1 .. ims-1])); 471 i = skipLine(ims, text); 472 473 // Is it safe to assume that the entries are always sequential? 474 value_plural ~= plural; 475 } 476 } 477 478 // Add the translation for the current language 479 if (key == "") { 480 nplurals_expr = parse_nplurals(value); 481 plural_func_expr = parse_plural_expression(value); 482 } 483 484 declStrings ~= DeclString(context, key, key_plural, value, value_plural); 485 } 486 487 return LangComponents(declStrings, nplurals_expr, plural_func_expr); 488 } 489 490 // Verify that two simple messages can be read and parsed correctly 491 unittest { 492 auto str = ` 493 # first string 494 msgid "ordinal.1" 495 msgstr "first" 496 497 # second string 498 msgid "ordinal.2" 499 msgstr "second"`; 500 501 auto components = extractDeclStrings(str); 502 auto ds = components.messages; 503 assert(2 == ds.length, "Not enough DeclStrings have been processed"); 504 assert(ds[0].key == "ordinal.1", "The first key is not right."); 505 assert(ds[0].value == "first", "The first value is not right."); 506 assert(ds[1].key == "ordinal.2", "The second key is not right."); 507 assert(ds[1].value == "second", "The second value is not right."); 508 } 509 510 // Verify that the fields cannot be defined out of order 511 unittest { 512 import core.exception : AssertError; 513 import std.exception : assertThrown; 514 515 auto str1 = ` 516 # unexpected field ahead 517 msgstr "world" 518 msgid "hello"`; 519 520 assertThrown!AssertError(extractDeclStrings(str1)); 521 } 522 523 // Verify that string wrapping is handled correctly 524 unittest { 525 auto str = ` 526 # The following text is wrapped 527 msgid "" 528 "This is an example of text that " 529 "has been wrapped on two lines." 530 msgstr "" 531 "It should not matter where it takes place, " 532 "the strings should all be concatenated properly."`; 533 534 auto ds = extractDeclStrings(str).messages; 535 assert(1 == ds.length, "Expected one DeclString to have been processed."); 536 assert(ds[0].key == "This is an example of text that has been wrapped on two lines.", "Failed to properly wrap the key"); 537 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"); 538 } 539 540 // Verify that string wrapping and unescaping is handled correctly on example of PO headers 541 unittest { 542 auto str = ` 543 # English translations for ThermoWebUI package. 544 # This file is put in the public domain. 545 # Automatically generated, 2015. 546 # 547 msgid "" 548 msgstr "" 549 "Project-Id-Version: PROJECT VERSION\n" 550 "Report-Msgid-Bugs-To: developer@example.com\n" 551 "POT-Creation-Date: 2015-04-13 17:55+0600\n" 552 "PO-Revision-Date: 2015-04-13 14:13+0600\n" 553 "Last-Translator: Automatically generated\n" 554 "Language-Team: none\n" 555 "Language: en\n" 556 "MIME-Version: 1.0\n" 557 "Content-Type: text/plain; charset=UTF-8\n" 558 "Content-Transfer-Encoding: 8bit\n" 559 "Plural-Forms: nplurals=2; plural=(n != 1);\n" 560 `; 561 auto expected = `Project-Id-Version: PROJECT VERSION 562 Report-Msgid-Bugs-To: developer@example.com 563 POT-Creation-Date: 2015-04-13 17:55+0600 564 PO-Revision-Date: 2015-04-13 14:13+0600 565 Last-Translator: Automatically generated 566 Language-Team: none 567 Language: en 568 MIME-Version: 1.0 569 Content-Type: text/plain; charset=UTF-8 570 Content-Transfer-Encoding: 8bit 571 Plural-Forms: nplurals=2; plural=(n != 1); 572 `; 573 574 auto ds = extractDeclStrings(str).messages; 575 assert(1 == ds.length, "Expected one DeclString to have been processed."); 576 assert(ds[0].key == "", "Failed to properly wrap or unescape the key"); 577 assert(ds[0].value == expected, "Failed to properly wrap or unescape the value"); 578 } 579 580 // Verify that the message context is properly parsed 581 unittest { 582 auto str1 = ` 583 # "C" is for cookie 584 msgctxt "food" 585 msgid "C" 586 msgstr "C is for cookie, that's good enough for me."`; 587 588 auto ds1 = extractDeclStrings(str1).messages; 589 assert(1 == ds1.length, "Expected one DeclString to have been processed."); 590 assert(ds1[0].context == "food", "Expected a context of food"); 591 assert(ds1[0].key == "C", "Expected to find the letter C for the msgid."); 592 assert(ds1[0].value == "C is for cookie, that's good enough for me.", "Unexpected value encountered for the msgstr."); 593 594 auto str2 = ` 595 # No context validation 596 msgid "alpha" 597 msgstr "First greek letter."`; 598 599 auto ds2 = extractDeclStrings(str2).messages; 600 assert(1 == ds2.length, "Expected one DeclString to have been processed."); 601 assert(ds2[0].context is null, "Expected the context to be null when it is not defined."); 602 } 603 604 unittest { 605 enum str = ` 606 # "C" is for cookie 607 msgctxt "food" 608 msgid "C" 609 msgstr "C is for cookie, that's good enough for me." 610 611 # "C" is for language 612 msgctxt "lang" 613 msgid "C" 614 msgstr "Catalan" 615 616 # Just "C" 617 msgid "C" 618 msgstr "Third letter" 619 `; 620 621 enum components = extractDeclStrings(str); 622 623 struct TranslationContext { 624 import std.typetuple; 625 enum enforceExistingKeys = true; 626 alias languages = TypeTuple!("en_US"); 627 628 // Note that this is normally handled by mixing in an external file. 629 enum en_US_unittest = components; 630 } 631 632 auto newTr(string msgid, string msgcntxt = null) { 633 return tr!(TranslationContext, "en_US")(msgid, msgcntxt); 634 } 635 636 assert(newTr("C", "food") == "C is for cookie, that's good enough for me.", "Unexpected translation based on context."); 637 assert(newTr("C", "lang") == "Catalan", "Unexpected translation based on context."); 638 assert(newTr("C") == "Third letter", "Unexpected translation based on context."); 639 } 640 641 unittest { 642 enum str = `msgid "" 643 msgstr "" 644 "Project-Id-Version: kstars\\n" 645 "Plural-Forms: nplurals=2; plural=n != 1;\\n" 646 647 msgid "One file was deleted." 648 msgid_plural "Files were deleted." 649 msgstr "One file was deleted." 650 msgstr[0] "1 file was deleted." 651 msgstr[1] "%d files were deleted." 652 653 msgid "One file was created." 654 msgid_plural "Several files were created." 655 msgstr "One file was created." 656 msgstr[0] "1 file was created" 657 msgstr[1] "%d files were created." 658 `; 659 660 import std.stdio; 661 enum components = extractDeclStrings(str); 662 663 struct TranslationContext { 664 import std.typetuple; 665 enum enforceExistingKeys = true; 666 alias languages = TypeTuple!("en_US"); 667 668 // Note that this is normally handled by mixing in an external file. 669 enum en_US_unittest2 = components; 670 } 671 672 auto newTr(string msgid, string msgid_plural, int count, string msgcntxt = null) { 673 return tr!(TranslationContext, "en_US")(msgid, msgid_plural, count, msgcntxt); 674 } 675 676 string expected = "1 file was deleted."; 677 auto actual = newTr("One file was deleted.", "Files were deleted.", 1); 678 assert(expected == actual, "Expected: '"~expected~"' but got '"~actual~"'"); 679 680 expected = "%d files were deleted."; 681 actual = newTr("One file was deleted.", "Files were deleted.", 42); 682 assert(expected == actual, "Expected: '"~expected~"' but got '"~actual~"'"); 683 } 684 685 private size_t skipToDirective(size_t i, ref string text) 686 { 687 while (i < text.length) { 688 i = skipWhitespace(i, text); 689 if (i < text.length && text[i] == '#') i = skipLine(i, text); 690 else break; 691 } 692 return i; 693 } 694 695 private size_t skipWhitespace(size_t i, ref string text) 696 { 697 while (i < text.length && (text[i] == ' ' || text[i] == '\t' || text[i] == '\n' || text[i] == '\r')) 698 i++; 699 return i; 700 } 701 702 private size_t skipLine(size_t i, ref string text) 703 { 704 while (i < text.length && text[i] != '\r' && text[i] != '\n') i++; 705 if (i+1 < text.length && (text[i+1] == '\r' || text[i+1] == '\n') && text[i] != text[i+1]) i++; 706 return i+1; 707 } 708 709 private size_t skipString(size_t i, ref string text) 710 { 711 import std.conv : to; 712 assert(text[i] == '"', "Expected to encounter the start of a string at position: "~to!string(i)); 713 i++; 714 while (true) { 715 assert(i < text.length, "Missing closing '\"' for string: "~text[i .. min($, 10)]); 716 if (text[i] == '"') { 717 if (i+1 < text.length) { 718 auto j = skipWhitespace(i+1, text); 719 if (j<text.length && text[j] == '"') return skipString(j, text); 720 } 721 return i+1; 722 } 723 if (text[i] == '\\') i += 2; 724 else i++; 725 } 726 } 727 728 private size_t skipIndex(size_t i, ref string text) { 729 import std.conv : to; 730 assert(text[i] == '[', "Expected to encounter a plural form of msgstr at position: "~to!string(i)); 731 for (; i<text.length; ++i) { 732 if (text[i] == ']') { 733 return i+1; 734 } 735 } 736 assert(false, "Missing a ']' for a msgstr in a translation file."); 737 } 738 739 private string wrapText(string str) 740 { 741 string ret; 742 bool wrapped = false; 743 744 for (size_t i=0; i<str.length; ++i) { 745 if (str[i] == '\\') { 746 assert(i+1 < str.length, "The string ends with the escape char: " ~ str); 747 ret ~= str[i..i+2]; 748 ++i; 749 } else if (str[i] == '"') { 750 wrapped = true; 751 size_t j = skipWhitespace(i+1, str); 752 if (j < str.length && str[j] == '"') { 753 i=j; 754 } 755 } else ret ~= str[i]; 756 } 757 758 if (wrapped) return ret; 759 return str; 760 } 761 762 private string parse_nplurals(string msgstr) 763 in { assert(msgstr, "An empty string cannot be parsed for Plural-Forms."); } 764 body { 765 import std.string : indexOf, CaseSensitive; 766 767 auto start = msgstr.indexOf("Plural-Forms:", CaseSensitive.no); 768 if (start > -1) { 769 auto beg = msgstr.indexOf("nplurals=", start+13, CaseSensitive.no); 770 if (beg > -1) { 771 auto end = msgstr.indexOf(';', beg+9, CaseSensitive.no); 772 if (end > -1) { 773 return msgstr[beg+9 .. end]; 774 } 775 return msgstr[beg+9 .. $]; 776 } 777 } 778 779 return null; 780 } 781 782 unittest { 783 auto res = parse_nplurals("Plural-Forms: nplurals=2; plural=n != 1;\n"); 784 assert(res == "2", "Failed to parse the correct number of plural forms for a language."); 785 } 786 787 private string parse_plural_expression(string msgstr) 788 in { assert(msgstr, "An empty string cannot be parsed for Plural-Forms."); } 789 body { 790 import std.string : indexOf, CaseSensitive; 791 792 auto start = msgstr.indexOf("Plural-Forms:", CaseSensitive.no); 793 if (start > -1) { 794 auto beg = msgstr.indexOf("plural=", start+13, CaseSensitive.no); 795 if (beg > -1) { 796 auto end = msgstr.indexOf(';', beg+7, CaseSensitive.no); 797 if (end > -1) { 798 return msgstr[beg+7 .. end]; 799 } 800 return msgstr[beg+7 .. $]; 801 } 802 } 803 804 return null; 805 } 806 807 unittest { 808 auto res = parse_plural_expression("Plural-Forms: nplurals=2; plural=n != 1;\n"); 809 assert(res == "n != 1", "Failed to parse the plural expression for a language."); 810 } 811 812 private string dstringUnescape(in string str) 813 { 814 string ret; 815 size_t i, start = 0; 816 for( i = 0; i < str.length; i++ ) 817 if( str[i] == '\\' ){ 818 if( i > start ){ 819 if( start > 0 ) ret ~= str[start .. i]; 820 else ret = str[0 .. i]; 821 } 822 assert(i+1 < str.length, "The string ends with the escape char: " ~ str); 823 switch(str[i+1]){ 824 default: ret ~= str[i+1]; break; 825 case 'r': ret ~= '\r'; break; 826 case 'n': ret ~= '\n'; break; 827 case 't': ret ~= '\t'; break; 828 } 829 i++; 830 start = i+1; 831 } 832 833 if( i > start ){ 834 if( start == 0 ) return str; 835 else ret ~= str[start .. i]; 836 } 837 return ret; 838 }