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 hb.web.i18n;
9 
10 import vibe.http.server : HTTPServerRequest;
11 import vibe.templ.parsertools;
12 
13 import std.algorithm : canFind, min, startsWith;
14 
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 hb.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 	struct TranslationContext {
35 		import std.typetuple;
36 		alias languages = TypeTuple!("en_US", "de_DE", "fr_FR");
37 		//mixin translationModule!"app";
38 		//mixin translationModule!"somelib";
39 	}
40 
41 	@translationContext!TranslationContext
42 	class MyWebInterface {
43 		void getHome()
44 		{
45 			//render!("home.dt")
46 		}
47 	}
48 }
49 
50 /// Defining a custom function for determining the language.
51 unittest {
52 	import vibe.http.server;
53 
54 	struct TranslationContext {
55 		import std.typetuple;
56 		alias languages = TypeTuple!("en_US", "de_DE", "fr_FR");
57 		//mixin translationModule!"app";
58 		//mixin translationModule!"somelib";
59 
60 		// use language settings from the session instead of using the
61 		// "Accept-Language" header
62 		static string determineLanguage(scope HTTPServerRequest req)
63 		{
64 			if (!req.session) return null; // use default language
65 			return req.session.get("language", "");
66 		}
67 	}
68 
69 	@translationContext!TranslationContext
70 	class MyWebInterface {
71 		void getHome()
72 		{
73 			//render!("home.dt")
74 		}
75 	}
76 }
77 
78 
79 struct TranslationContextAttribute(CONTEXT) {
80 	alias Context = CONTEXT;
81 }
82 
83 /*
84 doctype 5
85 html
86 	body
87 		p& Hello, World!
88 		p& This is a translated version of #{appname}.
89 	html
90 		p(class="Sasdasd")&.
91 			This is a complete paragraph of translated text.
92 */
93 
94 /** Makes a set of PO files available to a web interface class.
95 
96 	This mixin template needs to be mixed in at the class scope. It will parse all
97 	translation files with the specified file name prefix and make their
98 	translations available.
99 
100 	Params:
101 		FILENAME = Base name of the set of PO files to mix in. A file with the
102 			name "<FILENAME>.<LANGUAGE>.po" must be available as a string import
103 			for each language defined in the translation context.
104 
105 	Bugs:
106 		`FILENAME` should not contain (back)slash characters, as string imports
107 		from sub directories will currently fail on Windows. See
108 		$(LINK https://issues.dlang.org/show_bug.cgi?id=14349).
109 
110 	See_Also: `translationContext`
111 */
112 mixin template translationModule(string FILENAME)
113 {
114 	import std.string : tr;
115 	enum NAME = FILENAME.tr(`/.-\`, "____");
116 	private mixin template file_mixin(size_t i) {
117 		static if (i < languages.length) {
118 			enum components = extractDeclStrings(import(FILENAME~"."~languages[i]~".po"));
119 			mixin("enum "~languages[i]~"_"~NAME~" = components;");
120 			//mixin decls_mixin!(languages[i], 0);
121 			mixin file_mixin!(i+1);
122 		}
123 	}
124 
125 	mixin file_mixin!0;
126 }
127 
128 /**
129 	Performs the string translation for a statically given language.
130 
131 	The second overload takes a plural form and a number to select from a set
132 	of translations based on the plural forms of the target language.
133 */
134 template tr(CTX, string LANG)
135 {
136 	string tr(string key, string context = null)
137 	{
138 		return tr!(CTX, LANG)(key, null, 0, context);
139 	}
140 
141 	string tr(string key, string key_plural, int n, string context = null)
142 	{
143 		static assert([CTX.languages].canFind(LANG), "Unknown language: "~LANG);
144 
145 		foreach (i, mname; __traits(allMembers, CTX)) {
146 			static if (mname.startsWith(LANG~"_")) {
147 				enum langComponents = __traits(getMember, CTX, mname);
148 				foreach (entry; langComponents.messages) {
149 					if ((context is null) == (entry.context is null)) {
150 						if (context is null || entry.context == context) {
151 							if (entry.key == key) {
152 								if (key_plural !is null) {
153 									if (entry.pluralKey !is null && entry.pluralKey == key_plural) {
154 										static if (langComponents.nplurals_expr !is null && langComponents.plural_func_expr !is null) {
155 											mixin("int nplurals = "~langComponents.nplurals_expr~";");
156 											if (nplurals > 0) {
157 												mixin("int index = "~langComponents.plural_func_expr~";");
158 												return entry.pluralValues[index];
159 											}
160 											return entry.value;
161 										}
162 										assert(false, "Plural translations are not supported when the po file does not contain an entry for Plural-Forms.");
163 									}
164 								} else {
165 									return entry.value;
166 								}
167 							}
168 						}
169 					}
170 				}
171 			}
172 		}
173 
174 		static if (is(typeof(CTX.enforceExistingKeys)) && CTX.enforceExistingKeys) {
175 			if (key_plural !is null) {
176 				if (context is null) {
177 					assert(false, "Missing translation keys for "~LANG~": "~key~"&"~key_plural);
178 				}
179 				assert(false, "Missing translation key for "~LANG~"; "~context~": "~key~"&"~key_plural);
180 			}
181 
182 			if (context is null) {
183 				assert(false, "Missing translation key for "~LANG~": "~key);
184 			}
185 			assert(false, "Missing translation key for "~LANG~"; "~context~": "~key);
186 		} else {
187 			return n == 1 || !key_plural.length ? key : key_plural;
188 		}
189 	}
190 }
191 
192 package string determineLanguage(alias METHOD)(scope HTTPServerRequest req)
193 {
194 	import std.string : indexOf;
195 	import std.array;
196 
197 	alias CTX = GetTranslationContext!METHOD;
198 
199 	static if (!is(CTX == void)) {
200 		static if (is(typeof(CTX.determineLanguage(req)))) {
201 			static assert(is(typeof(CTX.determineLanguage(req)) == string),
202 				"determineLanguage in a translation context must return a language string.");
203 			return CTX.determineLanguage(req);
204 		} else {
205 			auto accept_lang = req.headers.get("Accept-Language", null);
206 
207 			size_t csidx = 0;
208 			while (accept_lang.length) {
209 				auto cidx = accept_lang[csidx .. $].indexOf(',');
210 				if (cidx < 0) cidx = accept_lang.length;
211 				auto entry = accept_lang[csidx .. csidx + cidx];
212 				auto sidx = entry.indexOf(';');
213 				if (sidx < 0) sidx = entry.length;
214 				auto entrylang = entry[0 .. sidx];
215 
216 				foreach (lang; CTX.languages) {
217 					if (entrylang == replace(lang, "_", "-")) return lang;
218 					if (entrylang == split(lang, "_")[0]) return lang; // FIXME: ensure that only one single-lang entry exists!
219 				}
220 
221 				if (cidx >= accept_lang.length) break;
222 				accept_lang = accept_lang[cidx+1 .. $];
223 			}
224 
225 			return null;
226 		}
227 	} else return null;
228 }
229 
230 unittest { // make sure that the custom determineLanguage is called
231 	static struct CTX {
232 		static string determineLanguage(Object a) { return "test"; }
233 	}
234 	@translationContext!CTX
235 	static class Test {
236 		void test()
237 		{
238 		}
239 	}
240 	auto test = new Test;
241 	assert(determineLanguage!(test.test)(null) == "test");
242 }
243 
244 package template GetTranslationContext(alias METHOD)
245 {
246 	import vibe.internal.meta.uda;
247 
248 	alias PARENT = typeof(__traits(parent, METHOD).init);
249 	enum FUNCTRANS = findFirstUDA!(TranslationContextAttribute, METHOD);
250 	enum PARENTTRANS = findFirstUDA!(TranslationContextAttribute, PARENT);
251 	static if (FUNCTRANS.found) alias GetTranslationContext = FUNCTRANS.value.Context;
252 	else static if (PARENTTRANS.found) alias GetTranslationContext = PARENTTRANS.value.Context;
253 	else alias GetTranslationContext = void;
254 }
255 
256 
257 private struct DeclString {
258 	string context;
259 	string key;
260 	string pluralKey;
261 	string value;
262 	string[] pluralValues;
263 }
264 
265 private struct LangComponents {
266 	DeclString[] messages;
267 	string nplurals_expr;
268 	string plural_func_expr;
269 }
270 
271 // Example po header
272 /*
273  * # Translation of kstars.po into Spanish.
274  * # This file is distributed under the same license as the kdeedu package.
275  * # Pablo de Vicente <pablo@foo.com>, 2005, 2006, 2007, 2008.
276  * # Eloy Cuadra <eloy@bar.net>, 2007, 2008.
277  * msgid ""
278  * msgstr ""
279  * "Project-Id-Version: kstars\n"
280  * "Report-Msgid-Bugs-To: http://bugs.kde.org\n"
281  * "POT-Creation-Date: 2008-09-01 09:37+0200\n"
282  * "PO-Revision-Date: 2008-07-22 18:13+0200\n"
283  * "Last-Translator: Eloy Cuadra <eloy@bar.net>\n"
284  * "Language-Team: Spanish <kde-l10n-es@kde.org>\n"
285  * "MIME-Version: 1.0\n"
286  * "Content-Type: text/plain; charset=UTF-8\n"
287  * "Content-Transfer-Encoding: 8bit\n"
288  * "Plural-Forms: nplurals=2; plural=n != 1;\n"
289  */
290 
291 // PO format notes
292 /*
293  * # - Translator comment
294  * #: - source reference
295  * #. - extracted comments
296  * #, - flags such as "c-format" to indicate what king of substitutions may be present
297  * #| msgid - previous string comment
298  * #~ - obsolete message
299  * msgctxt - disabmbiguating context, like variable scope (optional, defaults to null)
300  * msgid - key to translate from (required)
301  * msgid_plural - plural form of the msg id (optional)
302  * msgstr - value to translate to (required)
303  * msgstr[0] - indexed translation for handling the various plural forms
304  * msgstr[1] - ditto
305  * msgstr[2] - ditto and etc...
306  */
307 
308 LangComponents extractDeclStrings(string text)
309 {
310 	DeclString[] declStrings;
311 	string nplurals_expr;
312 	string plural_func_expr;
313 
314 	size_t i = 0;
315 	while (true) {
316 		i = skipToDirective(i, text);
317 		if (i >= text.length) break;
318 
319 		string context = null;
320 
321 		// msgctxt is an optional field
322 		if (text.length - i >= 7 && text[i .. i+7] == "msgctxt") {
323 			i = skipWhitespace(i+7, text);
324 
325 			auto icntxt = skipString(i, text);
326 			context = dstringUnescape(wrapText(text[i+1 .. icntxt-1]));
327 			i = skipToDirective(icntxt, text);
328 		}
329 
330 		// msgid is a required field
331 		assert(text.length - i >= 5 && text[i .. i+5] == "msgid", "Expected 'msgid', got '"~text[i .. min(i+10, $)]~"'.");
332 		i += 5;
333 
334 		i = skipWhitespace(i, text);
335 
336 		auto iknext = skipString(i, text);
337 		auto key = dstringUnescape(wrapText(text[i+1 .. iknext-1]));
338 		i = iknext;
339 
340 		i = skipToDirective(i, text);
341 
342 		// msgid_plural is an optional field
343 		string key_plural = null;
344 		if (text.length - i >= 12 && text[i .. i+12] == "msgid_plural") {
345 			i = skipWhitespace(i+12, text);
346 			auto iprl = skipString(i, text);
347 			key_plural = dstringUnescape(wrapText(text[i+1 .. iprl-1]));
348 			i = skipToDirective(iprl, text);
349 		}
350 
351 		// msgstr is a required field
352 		assert(text.length - i >= 6 && text[i .. i+6] == "msgstr", "Expected 'msgstr', got '"~text[i .. min(i+10, $)]~"'.");
353 		i += 6;
354 
355 		i = skipWhitespace(i, text);
356 		auto ivnext = skipString(i, text);
357 		auto value = dstringUnescape(wrapText(text[i+1 .. ivnext-1]));
358 		i = ivnext;
359 		i = skipToDirective(i, text);
360 
361 		// msgstr[n] is a required field when msgid_plural is not null, and ignored otherwise
362 		string[] value_plural;
363 		if (key_plural !is null) {
364 			while (text.length - i >= 6 && text[i .. i+6] == "msgstr") {
365 				i = skipIndex(i+6, text);
366 				i = skipWhitespace(i, text);
367 				auto ims = skipString(i, text);
368 
369 				string plural = dstringUnescape(wrapText(text[i+1 .. ims-1]));
370 				i = skipLine(ims, text);
371 
372 				// Is it safe to assume that the entries are always sequential?
373 				value_plural ~= plural;
374 			}
375 		}
376 
377 		// Add the translation for the current language
378 		if (key == "") {
379 			nplurals_expr = parse_nplurals(value);
380 			plural_func_expr = parse_plural_expression(value);
381 		}
382 
383 		declStrings ~= DeclString(context, key, key_plural, value, value_plural);
384 	}
385 
386 	return LangComponents(declStrings, nplurals_expr, plural_func_expr);
387 }
388 
389 // Verify that two simple messages can be read and parsed correctly
390 unittest {
391 	auto str = `
392 # first string
393 msgid "ordinal.1"
394 msgstr "first"
395 
396 # second string
397 msgid "ordinal.2"
398 msgstr "second"`;
399 
400 	auto components = extractDeclStrings(str);
401 	auto ds = components.messages;
402 	assert(2 == ds.length, "Not enough DeclStrings have been processed");
403 	assert(ds[0].key == "ordinal.1", "The first key is not right.");
404 	assert(ds[0].value == "first", "The first value is not right.");
405 	assert(ds[1].key == "ordinal.2", "The second key is not right.");
406 	assert(ds[1].value == "second", "The second value is not right.");
407 }
408 
409 // Verify that the fields cannot be defined out of order
410 unittest {
411 	import core.exception : AssertError;
412 	import std.exception : assertThrown;
413 
414 	auto str1 = `
415 # unexpected field ahead
416 msgstr "world"
417 msgid "hello"`;
418 
419 	assertThrown!AssertError(extractDeclStrings(str1));
420 }
421 
422 // Verify that string wrapping is handled correctly
423 unittest {
424 	auto str = `
425 # The following text is wrapped
426 msgid ""
427 "This is an example of text that "
428 "has been wrapped on two lines."
429 msgstr ""
430 "It should not matter where it takes place, "
431 "the strings should all be concatenated properly."`;
432 
433 	auto ds = extractDeclStrings(str).messages;
434 	assert(1 == ds.length, "Expected one DeclString to have been processed.");
435 	assert(ds[0].key == "This is an example of text that has been wrapped on two lines.", "Failed to properly wrap the key");
436 	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");
437 }
438 
439 // Verify that string wrapping and unescaping is handled correctly on example of PO headers
440 unittest {
441 	auto str = `
442 # English translations for ThermoWebUI package.
443 # This file is put in the public domain.
444 # Automatically generated, 2015.
445 #
446 msgid ""
447 msgstr ""
448 "Project-Id-Version: PROJECT VERSION\n"
449 "Report-Msgid-Bugs-To: developer@example.com\n"
450 "POT-Creation-Date: 2015-04-13 17:55+0600\n"
451 "PO-Revision-Date: 2015-04-13 14:13+0600\n"
452 "Last-Translator: Automatically generated\n"
453 "Language-Team: none\n"
454 "Language: en\n"
455 "MIME-Version: 1.0\n"
456 "Content-Type: text/plain; charset=UTF-8\n"
457 "Content-Transfer-Encoding: 8bit\n"
458 "Plural-Forms: nplurals=2; plural=(n != 1);\n"
459 `;
460 	auto expected = `Project-Id-Version: PROJECT VERSION
461 Report-Msgid-Bugs-To: developer@example.com
462 POT-Creation-Date: 2015-04-13 17:55+0600
463 PO-Revision-Date: 2015-04-13 14:13+0600
464 Last-Translator: Automatically generated
465 Language-Team: none
466 Language: en
467 MIME-Version: 1.0
468 Content-Type: text/plain; charset=UTF-8
469 Content-Transfer-Encoding: 8bit
470 Plural-Forms: nplurals=2; plural=(n != 1);
471 `;
472 
473 	auto ds = extractDeclStrings(str).messages;
474 	assert(1 == ds.length, "Expected one DeclString to have been processed.");
475 	assert(ds[0].key == "", "Failed to properly wrap or unescape the key");
476 	assert(ds[0].value == expected, "Failed to properly wrap or unescape the value");
477 }
478 
479 // Verify that the message context is properly parsed
480 unittest {
481 	auto str1 = `
482 # "C" is for cookie
483 msgctxt "food"
484 msgid "C"
485 msgstr "C is for cookie, that's good enough for me."`;
486 
487 	auto ds1 = extractDeclStrings(str1).messages;
488 	assert(1 == ds1.length, "Expected one DeclString to have been processed.");
489 	assert(ds1[0].context == "food", "Expected a context of food");
490 	assert(ds1[0].key == "C", "Expected to find the letter C for the msgid.");
491 	assert(ds1[0].value == "C is for cookie, that's good enough for me.", "Unexpected value encountered for the msgstr.");
492 
493 	auto str2 = `
494 # No context validation
495 msgid "alpha"
496 msgstr "First greek letter."`;
497 
498 	auto ds2 = extractDeclStrings(str2).messages;
499 	assert(1 == ds2.length, "Expected one DeclString to have been processed.");
500 	assert(ds2[0].context is null, "Expected the context to be null when it is not defined.");
501 }
502 
503 unittest {
504 	enum str = `
505 # "C" is for cookie
506 msgctxt "food"
507 msgid "C"
508 msgstr "C is for cookie, that's good enough for me."
509 
510 # "C" is for language
511 msgctxt "lang"
512 msgid "C"
513 msgstr "Catalan"
514 
515 # Just "C"
516 msgid "C"
517 msgstr "Third letter"
518 `;
519 
520 	enum components = extractDeclStrings(str);
521 
522 	struct TranslationContext {
523 		import std.typetuple;
524 		enum enforceExistingKeys = true;
525 		alias languages = TypeTuple!("en_US");
526 
527 		// Note that this is normally handled by mixing in an external file.
528 		enum en_US_unittest = components;
529 	}
530 
531 	auto newTr(string msgid, string msgcntxt = null) {
532 		return tr!(TranslationContext, "en_US")(msgid, msgcntxt);
533 	}
534 
535 	assert(newTr("C", "food") == "C is for cookie, that's good enough for me.", "Unexpected translation based on context.");
536 	assert(newTr("C", "lang") == "Catalan", "Unexpected translation based on context.");
537 	assert(newTr("C") == "Third letter", "Unexpected translation based on context.");
538 }
539 
540 unittest {
541 	enum str = `msgid ""
542 msgstr ""
543 "Project-Id-Version: kstars\\n"
544 "Plural-Forms: nplurals=2; plural=n != 1;\\n"
545 
546 msgid "One file was deleted."
547 msgid_plural "Files were deleted."
548 msgstr "One file was deleted."
549 msgstr[0] "1 file was deleted."
550 msgstr[1] "%d files were deleted."
551 
552 msgid "One file was created."
553 msgid_plural "Several files were created."
554 msgstr "One file was created."
555 msgstr[0] "1 file was created"
556 msgstr[1] "%d files were created."
557 `;
558 
559 	import std.stdio;
560 	enum components = extractDeclStrings(str);
561 
562 	struct TranslationContext {
563 		import std.typetuple;
564 		enum enforceExistingKeys = true;
565 		alias languages = TypeTuple!("en_US");
566 
567 		// Note that this is normally handled by mixing in an external file.
568 		enum en_US_unittest2 = components;
569 	}
570 	
571 	auto newTr(string msgid, string msgid_plural, int count, string msgcntxt = null) {
572 		return tr!(TranslationContext, "en_US")(msgid, msgid_plural, count, msgcntxt);
573 	}
574 
575 	string expected = "1 file was deleted.";
576 	auto actual = newTr("One file was deleted.", "Files were deleted.", 1);
577 	assert(expected == actual, "Expected: '"~expected~"' but got '"~actual~"'");
578 
579 	expected = "%d files were deleted.";
580 	actual = newTr("One file was deleted.", "Files were deleted.", 42);
581 	assert(expected == actual, "Expected: '"~expected~"' but got '"~actual~"'");
582 }
583 
584 private size_t skipToDirective(size_t i, ref string text)
585 {
586 	while (i < text.length) {
587 		i = skipWhitespace(i, text);
588 		if (i < text.length && text[i] == '#') i = skipLine(i, text);
589 		else break;
590 	}
591 	return i;
592 }
593 
594 private size_t skipWhitespace(size_t i, ref string text)
595 {
596 	while (i < text.length && (text[i] == ' ' || text[i] == '\t' || text[i] == '\n' || text[i] == '\r'))
597 		i++;
598 	return i;
599 }
600 
601 private size_t skipLine(size_t i, ref string text)
602 {
603 	while (i < text.length && text[i] != '\r' && text[i] != '\n') i++;
604 	if (i+1 < text.length && (text[i+1] == '\r' || text[i+1] == '\n') && text[i] != text[i+1]) i++;
605 	return i+1;
606 }
607 
608 private size_t skipString(size_t i, ref string text)
609 {
610 	import std.conv : to;
611 	assert(text[i] == '"', "Expected to encounter the start of a string at position: "~to!string(i));
612 	i++;
613 	while (true) {
614 		assert(i < text.length, "Missing closing '\"' for string: "~text[i .. min($, 10)]);
615 		if (text[i] == '"') {
616 			if (i+1 < text.length) {
617 				auto j = skipWhitespace(i+1, text);
618 				if (j<text.length && text[j] == '"') return skipString(j, text);
619 			}
620 			return i+1;
621 		}
622 		if (text[i] == '\\') i += 2;
623 		else i++;
624 	}
625 }
626 
627 private size_t skipIndex(size_t i, ref string text) {
628 	import std.conv : to;
629 	assert(text[i] == '[', "Expected to encounter a plural form of msgstr at position: "~to!string(i));
630 	for (; i<text.length; ++i) {
631 		if (text[i] == ']') {
632 			return i+1;
633 		}
634 	}
635 	assert(false, "Missing a ']' for a msgstr in a translation file.");
636 }
637 
638 private string wrapText(string str)
639 {
640 	string ret;
641 	bool wrapped = false;
642 
643 	for (size_t i=0; i<str.length; ++i) {
644 		if (str[i] == '\\') {
645 			assert(i+1 < str.length, "The string ends with the escape char: " ~ str);
646 			ret ~= str[i..i+2];
647 			++i;
648 		} else if (str[i] == '"') {
649 			wrapped = true;
650 			size_t j = skipWhitespace(i+1, str);
651 			if (j < str.length && str[j] == '"') {
652 				i=j;
653 			}
654 		} else ret ~= str[i];
655 	}
656 
657 	if (wrapped) return ret;
658 	return str;
659 }
660 
661 private string parse_nplurals(string msgstr)
662 in { assert(msgstr, "An empty string cannot be parsed for Plural-Forms."); }
663 body {
664 	import std.string : indexOf, CaseSensitive;
665 
666 	auto start = msgstr.indexOf("Plural-Forms:", CaseSensitive.no);
667 	if (start > -1) {
668 		auto beg = msgstr.indexOf("nplurals=", start+13, CaseSensitive.no);
669 		if (beg > -1) {
670 			auto end = msgstr.indexOf(';', beg+9, CaseSensitive.no);
671 			if (end > -1) {
672 				return msgstr[beg+9 .. end];
673 			}
674 			return msgstr[beg+9 .. $];
675 		}
676 	}
677 
678 	return null;
679 }
680 
681 unittest {
682 	auto res = parse_nplurals("Plural-Forms: nplurals=2; plural=n != 1;\n");
683 	assert(res == "2", "Failed to parse the correct number of plural forms for a language.");
684 }
685 
686 private string parse_plural_expression(string msgstr)
687 in { assert(msgstr, "An empty string cannot be parsed for Plural-Forms."); }
688 body {
689 	import std.string : indexOf, CaseSensitive;
690 
691 	auto start = msgstr.indexOf("Plural-Forms:", CaseSensitive.no);
692 	if (start > -1) {
693 		auto beg = msgstr.indexOf("plural=", start+13, CaseSensitive.no);
694 		if (beg > -1) {
695 			auto end = msgstr.indexOf(';', beg+7, CaseSensitive.no);
696 			if (end > -1) {
697 				return msgstr[beg+7 .. end];
698 			}
699 			return msgstr[beg+7 .. $];
700 		}
701 	}
702 
703 	return null;
704 }
705 
706 unittest {
707 	auto res = parse_plural_expression("Plural-Forms: nplurals=2; plural=n != 1;\n");
708 	assert(res == "n != 1", "Failed to parse the plural expression for a language.");
709 }