From 88e51f65b3c6861267db2f43b50eee7e22d3db38 Mon Sep 17 00:00:00 2001 From: will wade Date: Sun, 2 Aug 2026 07:50:06 +0100 Subject: [PATCH 1/2] feat(strings): add canonical locales.json (RFC 0003) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Strings/locales.json — the canonical, machine-readable list of locales DasherCore supports: {code, endonym, rtl} for all 33 locales that ship a strings_*.json file. This is the single source of truth for every frontend's locale picker, so they stop hardcoding divergent subsets (Apple 9, Windows 10, GTK 0). Pure data; no engine or API change. Generated to match the strings_*.json files exactly (33 == 33; RTL: ar, fa, ur). Frontends codegen/bind their pickers from this file (RFC 0003). Follow-ups (separate): a CI guard that asserts locales.json stays in sync with strings_*.json, and optionally a C API to query the list at runtime. Signed-off-by: will wade --- Strings/locales.json | 170 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 Strings/locales.json diff --git a/Strings/locales.json b/Strings/locales.json new file mode 100644 index 00000000..34e3c391 --- /dev/null +++ b/Strings/locales.json @@ -0,0 +1,170 @@ +{ + "_comment": "Canonical list of locales DasherCore supports. Mirrors the strings_*.json files in this directory — the single source of truth for every frontend's locale picker (RFC 0003). Display ordering is the frontend's concern. Regenerate with the table in RFC 0003; keep in sync with strings_*.json.", + "locales": [ + { + "code": "af", + "endonym": "Afrikaans", + "rtl": false + }, + { + "code": "ar", + "endonym": "العربية", + "rtl": true + }, + { + "code": "bn", + "endonym": "বাংলা", + "rtl": false + }, + { + "code": "cs", + "endonym": "Čeština", + "rtl": false + }, + { + "code": "da", + "endonym": "Dansk", + "rtl": false + }, + { + "code": "de", + "endonym": "Deutsch", + "rtl": false + }, + { + "code": "el", + "endonym": "Ελληνικά", + "rtl": false + }, + { + "code": "en", + "endonym": "English", + "rtl": false + }, + { + "code": "es", + "endonym": "Español", + "rtl": false + }, + { + "code": "fa", + "endonym": "فارسی", + "rtl": true + }, + { + "code": "fi", + "endonym": "Suomi", + "rtl": false + }, + { + "code": "fr", + "endonym": "Français", + "rtl": false + }, + { + "code": "gu", + "endonym": "ગુજરાતી", + "rtl": false + }, + { + "code": "hi", + "endonym": "हिन्दी", + "rtl": false + }, + { + "code": "hu", + "endonym": "Magyar", + "rtl": false + }, + { + "code": "it", + "endonym": "Italiano", + "rtl": false + }, + { + "code": "kn", + "endonym": "ಕನ್ನಡ", + "rtl": false + }, + { + "code": "ml", + "endonym": "മലയാളം", + "rtl": false + }, + { + "code": "mr", + "endonym": "मराठी", + "rtl": false + }, + { + "code": "nl", + "endonym": "Nederlands", + "rtl": false + }, + { + "code": "pa", + "endonym": "ਪੰਜਾਬੀ", + "rtl": false + }, + { + "code": "pl", + "endonym": "Polski", + "rtl": false + }, + { + "code": "pt", + "endonym": "Português (Brasil)", + "rtl": false + }, + { + "code": "pt-PT", + "endonym": "Português (Portugal)", + "rtl": false + }, + { + "code": "ru", + "endonym": "Русский", + "rtl": false + }, + { + "code": "sv", + "endonym": "Svenska", + "rtl": false + }, + { + "code": "sw", + "endonym": "Kiswahili", + "rtl": false + }, + { + "code": "ta", + "endonym": "தமிழ்", + "rtl": false + }, + { + "code": "te", + "endonym": "తెలుగు", + "rtl": false + }, + { + "code": "th", + "endonym": "ไทย", + "rtl": false + }, + { + "code": "ur", + "endonym": "اردو", + "rtl": true + }, + { + "code": "zh-CN", + "endonym": "中文(简体)", + "rtl": false + }, + { + "code": "zu", + "endonym": "isiZulu", + "rtl": false + } + ] +} From f717f1928a344f9eeb65919a9781f29981a2e25a Mon Sep 17 00:00:00 2001 From: will wade Date: Sun, 2 Aug 2026 08:13:14 +0100 Subject: [PATCH 2/2] test(strings): guard locales.json <-> strings_*.json sync (RFC 0003) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add tests/test_locales_sync.cpp: a doctest that fails if Strings/locales.json and the shipped strings_.json files drift apart in either direction — a new strings_xx.json missing from locales.json, or a locales.json entry with no file. Registered via dasher_add_test, so it runs in every CI matrix job. Self-contained (no JSON dependency): codes come from the filenames and a focused scan of locales.json. Verified locally — passes at 33==33, and fails loudly with a diagnostic when a strings_*.json is removed (restored after). Signed-off-by: will wade --- CMakeLists.txt | 1 + tests/test_locales_sync.cpp | 83 +++++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+) create mode 100644 tests/test_locales_sync.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index f06cb4b8..101f03cb 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -259,6 +259,7 @@ if(BUILD_CAPI) dasher_add_test(dasher_deterministic_tests test_deterministic.cpp) dasher_add_test(dasher_training_tests test_training.cpp) dasher_add_test(dasher_node_tree_tests test_node_tree.cpp) + dasher_add_test(dasher_locales_sync_tests test_locales_sync.cpp) # Phase B (characterization) — closes coverage gaps noted in the review. # - buffer_lifetime: the C API "valid until next call" contract diff --git a/tests/test_locales_sync.cpp b/tests/test_locales_sync.cpp new file mode 100644 index 00000000..bc624ef1 --- /dev/null +++ b/tests/test_locales_sync.cpp @@ -0,0 +1,83 @@ +// test_locales_sync.cpp — guard that Strings/locales.json (the canonical locale +// list, RFC 0003) stays in lock-step with the strings_*.json files actually +// shipped. Prevents silent drift: add a strings_xx.json but forget locales.json +// (or vice versa) and this test fails. +// +// Self-contained: no JSON dependency. Codes are pulled from the +// strings_.json filenames and from a focused scan of locales.json (a +// generated, controlled file whose "code" keys are the only `"code":` matches). + +#include "test_common.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; + +static fs::path strings_dir() { + return fs::path(get_test_data_dir()) / "Strings"; +} + +// Codes implied by the shipped strings_.json files. +static std::set shipped_codes() { + std::set codes; + for (const auto& entry : fs::directory_iterator(strings_dir())) { + const std::string name = entry.path().filename().string(); + const std::string pfx = "strings_"; + const std::string ext = ".json"; + if (name.rfind(pfx, 0) == 0 && name.size() > pfx.size() + ext.size() && + name.compare(name.size() - ext.size(), ext.size(), ext) == 0) { + codes.insert(name.substr(pfx.size(), name.size() - pfx.size() - ext.size())); + } + } + return codes; +} + +// Codes listed in locales.json, by scanning for `"code": ""`. +static std::set listed_codes() { + const fs::path file = strings_dir() / "locales.json"; + std::ifstream in(file); + INFO("could not open " << file.string()); + REQUIRE(in.good()); + std::stringstream ss; + ss << in.rdbuf(); + const std::string text = ss.str(); + const std::string needle = "\"code\":"; + std::set codes; + size_t pos = 0; + while ((pos = text.find(needle, pos)) != std::string::npos) { + pos += needle.size(); + const size_t q1 = text.find('"', pos); + if (q1 == std::string::npos) break; + const size_t q2 = text.find('"', q1 + 1); + if (q2 == std::string::npos) break; + codes.insert(text.substr(q1 + 1, q2 - q1 - 1)); + pos = q2 + 1; + } + return codes; +} + +TEST_CASE("locales.json matches the shipped strings_*.json files") { + const std::set shipped = shipped_codes(); + const std::set listed = listed_codes(); + + REQUIRE_FALSE(shipped.empty()); + MESSAGE("shipped=" << shipped.size() << " listed=" << listed.size()); + + std::set missing; // shipped but not listed + std::set extra; // listed but not shipped + std::set_difference(shipped.begin(), shipped.end(), listed.begin(), listed.end(), + std::inserter(missing, missing.begin())); + std::set_difference(listed.begin(), listed.end(), shipped.begin(), shipped.end(), + std::inserter(extra, extra.begin())); + + INFO("locales.json missing codes that have a strings_*.json file (missing=" << missing.size() << ")"); + REQUIRE(missing.empty()); + INFO("locales.json lists codes with no strings_*.json file (extra=" << extra.size() << ")"); + REQUIRE(extra.empty()); +}