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 }