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 }