10#include <simplesquirrel/simplesquirrel.hpp>
17#include <unordered_set>
26bool isPluralCategory(
const std::string&
key) {
27 static const char* cats[] = {
"zero",
"one",
"two",
"few",
"many",
"other"};
28 for (
const char*
c : cats)
29 if (
key ==
c) return true;
34std::string numberToString(
double v) {
35 std::ostringstream os;
40bool validIdentifier(
const std::string&
value) {
41 if (
value.empty() ||
value.size() > 64)
return false;
42 for (
const unsigned char ch :
value)
43 if (!((ch >=
'a' && ch <=
'z') || (ch >=
'A' && ch <=
'Z') || (ch >=
'0' && ch <=
'9') || ch ==
'-' ||
49bool collectPlaceholders(
const std::string&
text, std::set<std::string>& result, std::string&
error) {
53 error =
"unmatched closing placeholder brace";
60 const size_t close =
text.find(
'}',
offset + 1);
61 if (close == std::string::npos) {
62 error =
"unterminated placeholder";
66 if (!validIdentifier(
name)) {
67 error =
"placeholder names must be stable identifiers";
77 std::unordered_map<std::string, std::string>& strings,
78 std::unordered_map<std::string, std::unordered_map<std::string, std::string>>& plurals,
79 std::string&
error, std::string& errorPath) {
81 error =
"locale nesting exceeds 16 levels";
85 if (!
node.isObject() ||
node.size() == 0) {
86 error =
"translation namespaces must be non-empty objects";
90 const std::vector<std::string> names =
node.keys();
91 bool allPlural =
true;
92 bool anyPlural =
false;
93 for (
const auto&
name : names) {
94 if (isPluralCategory(
name))
98 if (!validIdentifier(
name)) {
99 error =
"translation key segments must be stable identifiers";
100 errorPath = prefix +
"." +
name;
104 if (anyPlural && !allPlural) {
105 error =
"plural categories cannot be mixed with namespace keys";
110 if (prefix.empty() || !
node.has(
"other")) {
111 error =
"plural tables require a non-empty key and an other form";
115 std::unordered_map<std::string, std::string> forms;
116 for (
const auto&
name : names) {
118 if (!
value.isString() ||
value.asString().empty() ||
value.asString().size() > 16384) {
119 error =
"plural forms must be non-empty strings of at most 16384 bytes";
120 errorPath = prefix +
"." +
name;
125 plurals.emplace(prefix, std::move(forms));
128 for (
const auto&
name : names) {
130 const std::string
path = prefix.empty() ?
name : prefix +
"." +
name;
131 if (
value.isString()) {
132 if (
value.asString().empty() ||
value.asString().size() > 16384) {
133 error =
"translations must be non-empty strings of at most 16384 bytes";
138 }
else if (
value.isObject()) {
139 if (!flattenStrict(
value,
path,
depth + 1, strings, plurals,
error, errorPath))
return false;
141 error =
"translations must be strings, namespaces, or plural tables";
145 if (strings.size() + plurals.size() > 10000) {
146 error =
"a locale may contain at most 10000 translation keys";
154void flatten(
eve::json::Value node,
const std::string& prefix, std::unordered_map<std::string, std::string>& strings,
155 std::unordered_map<std::string, std::unordered_map<std::string, std::string>>& plurals) {
156 if (
node.isObject()) {
157 const std::vector<std::string> names =
node.keys();
159 bool allCats = !names.empty();
160 bool allStrings =
true;
161 for (
const auto& k : names) {
162 if (!isPluralCategory(k)) {
166 if (!
node.get(k.c_str()).isString()) allStrings =
false;
168 if (allCats && allStrings && !prefix.empty()) {
169 std::unordered_map<std::string, std::string> forms;
170 for (
const auto& k : names) forms[k] =
node.getString(k.c_str());
171 plurals[prefix] = std::move(forms);
174 for (
const auto& k : names) {
175 const std::string
path = prefix.empty() ? k : prefix +
"." + k;
176 flatten(
node.get(k.c_str()),
path, strings, plurals);
180 if (prefix.empty())
return;
182 if (
node.isString() ||
node.isNumber() ||
node.isBool()) strings[prefix] =
node.asString();
185bool parseLocale(
const std::string&
text, std::unordered_map<std::string, std::string>& strings,
186 std::unordered_map<std::string, std::unordered_map<std::string, std::string>>& plurals,
187 std::string*
error) {
189 if (!doc.
valid())
return false;
191 if (
error) *
error =
"locale root must be a JSON object";
196 flatten(doc.
root(),
"", strings, plurals);
200int64_t fileModtime(
const std::string&
path) {
201 auto* fs = eve::ModuleManager::getInstance<eve::filesystem::Filesystem>(
"Filesystem");
202 if (!fs) fs = eve::filesystem::Filesystem::create();
204 if (!fs->getInfo(
path,
info))
return -1;
209std::unordered_map<std::string, std::string> readParams(ssq::Object
params) {
210 std::unordered_map<std::string, std::string> out;
213 const HSQOBJECT raw =
params.getRaw();
214 if (raw._type != OT_TABLE)
return out;
216 const SQInteger
top = sq_gettop(
vm);
217 sq_pushobject(
vm, raw);
219 while (SQ_SUCCEEDED(sq_next(
vm, -2))) {
221 const SQChar* k =
nullptr;
222 if (sq_gettype(
vm, -2) == OT_STRING && SQ_SUCCEEDED(sq_getstring(
vm, -2, &k)) && k) {
224 const SQObjectType vt = sq_gettype(
vm, -1);
225 if (vt == OT_STRING) {
226 const SQChar*
sv =
nullptr;
227 if (SQ_SUCCEEDED(sq_getstring(
vm, -1, &sv)) && sv)
v =
sv;
228 }
else if (vt == OT_INTEGER) {
230 if (SQ_SUCCEEDED(sq_getinteger(
vm, -1, &iv)))
v = std::to_string(iv);
231 }
else if (vt == OT_FLOAT) {
233 if (SQ_SUCCEEDED(sq_getfloat(
vm, -1, &fv)))
v = numberToString(fv);
234 }
else if (vt == OT_BOOL) {
236 if (SQ_SUCCEEDED(sq_getbool(
vm, -1, &bv)))
v = bv ?
"true" :
"false";
238 out[k] = std::move(
v);
252I18n::Locale* I18n::findLocale(
const std::string& lang) {
253 auto it = locales_.find(lang);
254 return it == locales_.end() ? nullptr : &it->second;
257const I18n::Locale* I18n::findLocale(
const std::string& lang)
const {
258 auto it = locales_.find(lang);
259 return it == locales_.end() ? nullptr : &it->second;
268 if (!parseLocale(json, loc.strings, loc.plurals, &
error))
270 error.empty() ?
"invalid locale JSON" :
error,
271 "locale." + lang, {},
"i18n.bundle"));
274 locales_[lang] = std::move(loc);
283 std::string parseError;
285 if (!document.valid())
289 const auto root = document.root();
290 if (!
root.isObject())
292 "localization bundle root must be an object",
"$", {},
294 const std::unordered_set<std::string> rootFields = {
"schema",
"version",
"defaultLocale",
"locales"};
295 for (
const auto& field :
root.keys())
296 if (!rootFields.contains(field))
298 "localization bundle contains an unknown field",
299 "$." + field, {},
"i18n.bundle"));
300 const auto schema =
root.get(
"schema");
301 const auto version =
root.get(
"version");
302 const auto defaultLocale =
root.get(
"defaultLocale");
303 const auto locales =
root.get(
"locales");
304 if (!schema.isString() || schema.asString() !=
"eve.i18n.bundle")
306 "localization bundle schema id is invalid",
"$.schema",
308 if (!version.isInt64() || version.asInt64() != 1)
310 "unsupported localization bundle schema version",
311 "$.version", {},
"i18n.bundle"));
312 if (!defaultLocale.isString() || !validIdentifier(defaultLocale.asString()))
314 "defaultLocale must be a stable locale identifier",
315 "$.defaultLocale", {},
"i18n.bundle"));
316 if (!locales.isObject() || locales.size() == 0 || locales.size() > 32)
318 "locales must contain between 1 and 32 locale objects",
319 "$.locales", {},
"i18n.bundle"));
320 if (!locales.has(defaultLocale.asString().c_str()))
322 "defaultLocale is not present in locales",
323 "$.defaultLocale", {},
"i18n.bundle"));
325 std::unordered_map<std::string, Locale> proposed;
326 for (
const auto& lang : locales.keys()) {
327 if (!validIdentifier(lang))
329 "locale names must be stable identifiers",
330 "$.locales." + lang, {},
"i18n.bundle"));
333 std::string errorPath;
334 if (!flattenStrict(locales.get(lang.c_str()),
"", 0, candidate.strings, candidate.plurals,
error, errorPath))
337 "$.locales." + lang + (errorPath.empty() ?
"" :
"." + errorPath), {},
"i18n.bundle"));
338 proposed.emplace(lang, std::move(candidate));
341 const Locale&
source = proposed.at(defaultLocale.asString());
342 for (
const auto& [lang, locale] : proposed) {
343 for (
const auto& [
key, sourceText] :
source.strings) {
344 const auto translated = locale.strings.find(
key);
345 if (translated == locale.strings.end())
348 "$.locales." + lang +
"." +
key, {},
"i18n.bundle"));
349 std::set<std::string> expected;
350 std::set<std::string> actual;
351 std::string placeholderError;
352 if (!collectPlaceholders(sourceText, expected, placeholderError))
355 "$.locales." + defaultLocale.asString() +
"." +
key, {},
"i18n.bundle"));
356 if (!collectPlaceholders(translated->second, actual, placeholderError))
359 "$.locales." + lang +
"." +
key, {},
"i18n.bundle"));
360 if (expected != actual)
363 "$.locales." + lang +
"." +
key, {},
"i18n.bundle"));
365 for (
const auto& [
key, translated] : locale.strings)
369 "locale contains a singular key absent from the default locale",
370 "$.locales." + lang +
"." +
key, {},
"i18n.bundle"));
371 for (
const auto& [
key, sourceForms] :
source.plurals) {
372 const auto translated = locale.plurals.find(
key);
373 if (translated == locale.plurals.end())
376 "$.locales." + lang +
"." +
key, {},
"i18n.bundle"));
377 for (
const auto& [form, translatedText] : translated->second) {
378 const auto exactSource = sourceForms.find(form);
379 const auto fallbackSource = sourceForms.find(
"other");
380 const std::string& sourceText =
381 exactSource != sourceForms.end() ? exactSource->second : fallbackSource->second;
382 std::set<std::string> expected;
383 std::set<std::string> actual;
384 std::string placeholderError;
385 if (!collectPlaceholders(sourceText, expected, placeholderError))
388 "$.locales." + defaultLocale.asString() +
"." +
key +
"." + form, {},
"i18n.bundle"));
389 if (!collectPlaceholders(translatedText, actual, placeholderError))
392 "$.locales." + lang +
"." +
key +
"." + form, {},
"i18n.bundle"));
393 if (expected != actual)
396 "$.locales." + lang +
"." +
key +
"." + form, {},
"i18n.bundle"));
399 for (
const auto& [
key, forms] : locale.plurals) {
404 "$.locales." + lang +
"." +
key, {},
"i18n.bundle"));
408 const std::string nextDefault = defaultLocale.asString();
409 const std::string nextLanguage = proposed.contains(language_) ? language_ : nextDefault;
410 locales_ = std::move(proposed);
411 defaultLanguage_ = nextDefault;
412 language_ = nextLanguage;
417 if (lang.empty() ||
path.empty())
419 "locale identifier and VFS path must not be empty",
420 "locale.file", {},
"i18n.bundle"));
422 auto* fs = ModuleManager::getInstance<filesystem::Filesystem>(
"Filesystem");
423 if (!fs) fs = filesystem::Filesystem::create();
433 if (fd ==
nullptr || fd->
getData() ==
nullptr || fd->
getSize() == 0) {
444 if (!parseLocale(
text, loc.strings, loc.plurals, &
error))
449 loc.modtime = fileModtime(
path);
451 locales_[lang] = std::move(loc);
474 if (
index < 0 ||
size_t(
index) >= locales_.size())
return {};
475 std::vector<std::string> langs;
476 langs.reserve(locales_.size());
477 for (
const auto& [lang, loc] : locales_) langs.push_back(lang);
478 std::sort(langs.begin(), langs.end());
479 return langs[size_t(
index)];
485 const Locale* locale = findLocale(lang);
486 return locale && (locale->strings.contains(
key) || locale->plurals.contains(
key));
490 if (
key.empty() ||
key.back() ==
'.')
492 "translation key must not be empty",
"requiredKey", {},
496 const size_t separator =
key.find(
'.',
offset);
497 const std::string segment =
key.substr(
offset, separator == std::string::npos ? std::string::npos
499 if (!validIdentifier(segment))
502 "requiredKey." +
key, {},
"i18n.bundle"));
503 if (separator == std::string::npos)
break;
506 if (locales_.empty())
509 std::vector<std::string> languages;
510 languages.reserve(locales_.size());
511 for (
const auto& [language, locale] : locales_) languages.push_back(language);
512 std::sort(languages.begin(), languages.end());
513 for (
const auto& language : languages)
517 "locales." + language +
"." +
key, {},
"i18n.bundle"));
522 const Locale* locale = findLocale(lang);
523 return locale ?
static_cast<int>(locale->strings.size() + locale->plurals.size()) : 0;
527 const Locale* cur = findLocale(language_);
528 if (cur && (cur->strings.find(
key) != cur->strings.end() || cur->plurals.find(
key) != cur->plurals.end()))
530 if (defaultLanguage_ != language_) {
531 if (
const Locale* def = findLocale(defaultLanguage_))
532 if (def->strings.find(
key) != def->strings.end() || def->plurals.find(
key) != def->plurals.end())
539 if (
const Locale* cur = findLocale(language_)) {
540 auto it = cur->strings.find(
key);
541 if (it != cur->strings.end())
return it->second;
543 if (defaultLanguage_ != language_) {
544 if (
const Locale* def = findLocale(defaultLanguage_)) {
545 auto it = def->strings.find(
key);
546 if (it != def->strings.end())
return it->second;
552std::string I18n::formatString(
const std::string& tpl,
553 const std::unordered_map<std::string, std::string>&
params)
const {
555 out.reserve(tpl.size());
556 for (
size_t i = 0; i < tpl.size();) {
558 const size_t close = tpl.find(
'}', i + 1);
559 if (close != std::string::npos) {
560 const std::string
key = tpl.substr(i + 1, close - i - 1);
575 const std::unordered_map<std::string, std::string>&
params)
const {
582 const std::unordered_map<std::string, std::string>&
params)
const {
583 std::unordered_map<std::string, std::string> merged =
params;
584 merged[
"n"] = std::to_string(
n);
586 const Locale* cur = findLocale(language_);
587 const Locale* def = (defaultLanguage_ != language_) ? findLocale(defaultLanguage_) :
nullptr;
588 if (!cur && !def)
return key;
592 if (
const Locale* loc = cur) {
593 const auto pit = loc->plurals.find(
key);
594 if (pit != loc->plurals.end()) {
595 const std::string form = pluralForm(language_,
n);
596 auto it = pit->second.find(form);
597 if (it == pit->second.end()) it = pit->second.find(
"other");
598 if (it == pit->second.end()) it = pit->second.find(
"one");
599 if (it != pit->second.end())
return formatString(it->second, merged);
601 const auto sit = loc->strings.find(
key);
602 if (sit != loc->strings.end())
return formatString(sit->second, merged);
605 if (
const Locale* loc = def) {
606 const auto pit = loc->plurals.find(
key);
607 if (pit != loc->plurals.end()) {
608 const std::string form = pluralForm(defaultLanguage_,
n);
609 auto it = pit->second.find(form);
610 if (it == pit->second.end()) it = pit->second.find(
"other");
611 if (it == pit->second.end()) it = pit->second.find(
"one");
612 if (it != pit->second.end())
return formatString(it->second, merged);
614 const auto sit = loc->strings.find(
key);
615 if (sit != loc->strings.end())
return formatString(sit->second, merged);
621std::string I18n::pluralForm(
const std::string& lang,
int n)
const {
622 std::string base = lang;
623 if (
const size_t dash = base.find(
'-'); dash != std::string::npos) base = base.substr(0, dash);
624 if (
const size_t under = base.find(
'_'); under != std::string::npos) base = base.substr(0, under);
626 if (base ==
"zh" || base ==
"ja" || base ==
"ko" || base ==
"th" || base ==
"vi" || base ==
"id" || base ==
"ms" ||
627 base ==
"tr" || base ==
"my")
630 if (base ==
"fr" || base ==
"pt")
return (
n == 0 ||
n == 1) ?
"one" :
"other";
632 if (base ==
"ru" || base ==
"uk" || base ==
"be") {
633 const int mod10 =
n % 10;
634 const int mod100 =
n % 100;
635 if (mod10 == 1 && mod100 != 11)
return "one";
636 if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14))
return "few";
641 const int mod10 =
n % 10;
642 const int mod100 =
n % 100;
643 if (
n == 1)
return "one";
644 if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14))
return "few";
648 if (base ==
"cs" || base ==
"sk") {
649 if (
n == 1)
return "one";
650 if (
n >= 2 &&
n <= 4)
return "few";
654 return (
n == 1) ?
"one" :
"other";
659 if (!autoReload_)
return 0;
662 std::vector<std::string>
dirty;
663 for (
const auto& [lang, loc] : locales_) {
664 if (loc.path.empty())
continue;
665 const int64_t mt = fileModtime(loc.path);
666 if (mt >= 0 && mt != loc.modtime)
dirty.push_back(lang);
670 for (
const std::string& lang :
dirty) {
680void I18n::expose(ssq::Table& table) {
681 auto cls = table.addClass(
name, I18n::create,
false);
685void I18n::expose(ssq::Class&
cls) {
689 cls.addFunc(
"replaceLocaleFromJson", [
vm =
cls.getHandle()](
I18n* self,
const std::string& lang,
690 const std::string& json) {
692 return eve::script::projectResult(vm, eve::Result<void>::failure(eve::Diagnostic::error(
693 eve::DiagnosticCode::InvalidArgument,
694 "I18n receiver must not be null",
"i18n", {},
"i18n.squirrel")));
697 cls.addFunc(
"replaceLocaleFromFile", [
vm =
cls.getHandle()](I18n* self,
const std::string& lang,
698 const std::string&
path) {
702 "I18n receiver must not be null",
"i18n", {},
"i18n.squirrel")));
705 cls.addFunc(
"replaceBundleFromJson", [
vm =
cls.getHandle()](I18n* self,
const std::string& json) {
710 "I18n receiver must not be null",
"i18n", {},
"i18n.squirrel")),
713 [](
int count) { return eve::Value(count); });
718 cls.addFunc(
"selectLanguage", [
vm =
cls.getHandle()](I18n* self,
const std::string& lang) {
722 "I18n receiver must not be null",
"i18n", {},
"i18n.squirrel")));
732 cls.addFunc(
"validateKeyCoverage", [
vm =
cls.getHandle()](I18n* self,
const std::string&
key) {
737 "I18n receiver must not be null",
"i18n", {},
"i18n.squirrel")),
740 [](
int count) { return eve::Value(count); });
745 cls.addFunc(
"getWithParams", [](I18n* self,
const std::string&
key, ssq::Object
params) -> std::string {
746 return self->getWithParams(
key, readParams(
params));
749 cls.addFunc(
"getPluralWithParams",
750 [](I18n* self,
const std::string&
key,
int n, ssq::Object
params) -> std::string {
751 return self->getPluralWithParams(
key,
n, readParams(
params));
struct SQVM * HSQUIRRELVM
#define Module_IMPL(ModuleName, newExpr)
The single Squirrel projection for common Result, Status and Value.
const UnitySourceAsset & source
static Diagnostic error(DiagnosticCode code, std::string message, std::string path={}, DiagnosticDetails details={}, std::string source={})
Construct an error diagnostic with the standard error severity.
virtual std::string getName() const =0
Returns the name.
bool ok() const noexcept
Whether this result represents a non-failure outcome.
Move-only operation result carrying either a value or Status.
static Result success(T value)
Construct a successful result owning value.
static Result failure(Status status)
Construct a failed result from a structured status.
static Status success(StatusCode code=StatusCode::Ok)
Construct a successful status with an explicit non-error outcome.
The canonical owning dynamic value used by data-facing protocols.
Data buffer paired with a filename (used for type identification).
size_t getSize() const
Byte length of the owned buffer.
void * getData() const
Pointer to the owned byte buffer.
Game localization (i18n) module.
bool loadFromJson(const std::string &lang, const std::string &json)
Compatibility-only bool projection of replaceLocaleFromJson.
std::string getDefaultLanguage() const
Returns the default language.
std::string getPluralWithParams(const std::string &key, int n, const std::unordered_map< std::string, std::string > ¶ms) const
Returns the plural with params.
bool hasLanguage(const std::string &lang) const
True when language.
int update(float dt)
Re-read changed locale files; returns the number of reloads performed.
void setAutoReload(bool enable)
Sets the auto reload.
bool isAutoReload() const
True when auto reload.
eve::Result< void > selectLanguage(const std::string &lang)
Select an admitted language with a structured missing-locale diagnostic.
bool loadFromFile(const std::string &lang, const std::string &path)
Compatibility-only bool projection of replaceLocaleFromFile.
std::string getWithParams(const std::string &key, const std::unordered_map< std::string, std::string > ¶ms) const
Returns the with params.
void unload(const std::string &lang)
卸载 / 清空语言表。
bool hasInLanguage(const std::string &lang, const std::string &key) const
Return whether one exact locale owns key, without default-language substitution.
std::string getLanguage() const
Returns the language.
bool setLanguage(const std::string &lang)
Compatibility-only bool projection of selectLanguage.
bool has(const std::string &key) const
翻译查找:按键(点号命名空间)取字符串。
std::string getLanguageAt(int index) const
Returns the language at.
eve::Result< void > replaceLocaleFromJson(const std::string &lang, const std::string &json)
Atomically replace one locale from JSON with structured failure details.
int getKeyCount(const std::string &lang) const
Return the number of singular and plural keys owned by one locale, or zero if absent.
eve::Result< int > validateKeyCoverage(const std::string &key) const
Validate that every admitted locale owns one exact product key.
std::string getPlural(const std::string &key, int n) const
Returns the plural.
eve::Result< void > replaceLocaleFromFile(const std::string &lang, const std::string &path)
Atomically replace one locale from a VFS file with structured failure details.
std::string get(const std::string &key) const
Returns the value.
void setDefaultLanguage(const std::string &lang)
Sets the default language.
int getLanguageCount() const
Returns the language count.
eve::Result< int > replaceBundleFromJson(const std::string &json)
Atomically replace every locale from one strictly validated product bundle.
EVENGINE_API_FOUNDATION public API.
static Document parse(const std::string &text, std::string *error=nullptr)
Parse.
EVENGINE_API_FOUNDATION public API.
bool isObject() const
True when object.
std::vector< ParamSpec > params
WGPUStringView sv(const char *s)
Build a WGPUStringView from a C string (null-safe, length auto-computed).
ssq::Table projectResult(HSQUIRRELVM vm, Result< void > &&result)
Consume and project a void native Result using the common schema.