use crate::parser::Translation;
use crate::utils::{format, plurals};
use anyhow::{bail, Result};
use std::collections::{HashMap, HashSet};
const LUAU_KEYWORDS: &[&str] = &[
"and", "break", "continue", "do", "else", "elseif", "end", "false", "for", "function", "if",
"in", "local", "nil", "not", "or", "repeat", "return", "then", "true", "until", "while",
];
pub(crate) fn sanitize_luau_identifier(key: &str) -> String {
let mut result = String::with_capacity(key.len());
for ch in key.chars() {
if ch == '.' {
result.push('_');
} else if ch.is_ascii_alphanumeric() || ch == '_' {
result.push(ch);
} else {
result.push('_');
}
}
if result.chars().next().is_some_and(|c| c.is_ascii_digit()) {
result.insert(0, '_');
}
if LUAU_KEYWORDS.contains(&result.as_str()) {
result.insert(0, '_');
}
result
}
pub(crate) fn sanitize_segment(segment: &str) -> String {
let mut result = String::with_capacity(segment.len());
for ch in segment.chars() {
if ch.is_ascii_alphanumeric() || ch == '_' {
result.push(ch);
} else {
result.push('_');
}
}
if result.chars().next().is_some_and(|c| c.is_ascii_digit()) {
result.insert(0, '_');
}
if LUAU_KEYWORDS.contains(&result.as_str()) {
result.insert(0, '_');
}
result
}
pub(crate) fn sanitize_namespace_path(path: &str) -> String {
sanitized_path_parts(path).join(".")
}
pub(crate) fn sanitized_path_parts(path: &str) -> Vec<String> {
path.split('.').map(sanitize_segment).collect()
}
pub(crate) fn sanitized_namespace_prefixes(key: &str) -> Vec<String> {
let parts = sanitized_path_parts(key);
if parts.len() <= 1 {
return Vec::new();
}
(0..parts.len() - 1)
.map(|index| parts[0..=index].join("."))
.collect()
}
pub(crate) fn namespace_path_depth(path: &str) -> usize {
path.split('.')
.filter(|segment| !segment.is_empty())
.count()
}
pub(crate) fn sort_namespace_paths(paths: &mut [String]) {
paths.sort_by(|a, b| {
namespace_path_depth(a)
.cmp(&namespace_path_depth(b))
.then_with(|| a.cmp(b))
});
}
pub(crate) fn format_generated_luau(code: &str) -> String {
let mut formatted = String::new();
for line in code.lines() {
let line = line.trim_end();
let leading_spaces = line.bytes().take_while(|byte| *byte == b' ').count();
let tab_count = leading_spaces / 4;
let leftover_spaces = leading_spaces % 4;
formatted.push_str(&"\t".repeat(tab_count));
formatted.push_str(&" ".repeat(leftover_spaces));
formatted.push_str(&line[leading_spaces..]);
formatted.push('\n');
}
formatted
}
fn check_identifier_collisions(translations: &[&Translation]) -> Result<()> {
let mut seen: HashMap<String, &str> = HashMap::new();
for translation in translations {
let sanitized = sanitize_luau_identifier(&translation.key);
if let Some(original) = seen.get(&sanitized) {
if *original != translation.key {
bail!(
"Translation key collision after sanitization: '{}' and '{}' both produce method name '{}'. \
Rename one of the keys to avoid this conflict.",
original,
translation.key,
sanitized
);
}
} else {
seen.insert(sanitized, &translation.key);
}
}
Ok(())
}
pub fn generate_luau(translations: &[Translation], base_locale: &str) -> Result<String> {
generate_luau_with_config(translations, base_locale, None)
}
pub fn generate_luau_with_config(
translations: &[Translation],
base_locale: &str,
analytics_config: Option<&crate::config::AnalyticsConfig>,
) -> Result<String> {
generate_luau_with_full_config(translations, base_locale, analytics_config, None)
}
pub fn generate_luau_with_full_config(
translations: &[Translation],
base_locale: &str,
analytics_config: Option<&crate::config::AnalyticsConfig>,
localization_config: Option<&crate::config::LocalizationConfig>,
) -> Result<String> {
let mut code = String::new();
code.push_str("-- Generated by roblox-slang. DO NOT MODIFY BY HAND!\n\n");
let base_translations: Vec<_> = translations
.iter()
.filter(|t| t.locale == base_locale)
.collect();
if base_translations.is_empty() {
code.push_str("return {}\n");
return Ok(format_generated_luau(&code));
}
let mode = localization_config
.map(|c| c.mode.as_str())
.unwrap_or("embedded");
if !matches!(mode, "embedded" | "cloud" | "hybrid") {
bail!(
"Invalid localization mode: '{}'. Expected 'embedded', 'cloud', or 'hybrid'",
mode
);
}
if mode == "embedded" || mode == "hybrid" {
let supported_locales: Vec<String> = translations
.iter()
.map(|t| t.locale.clone())
.collect::<std::collections::HashSet<_>>()
.into_iter()
.collect();
generate_embedded_data(&mut code, translations, &supported_locales);
}
code.push_str("local Translations = {}\n");
code.push_str("Translations.__index = Translations\n\n");
match mode {
"embedded" => generate_constructor_embedded(&mut code, base_locale, analytics_config),
"cloud" => generate_constructor_cloud(&mut code, base_locale, analytics_config),
"hybrid" => generate_constructor_hybrid(&mut code, base_locale, analytics_config),
_ => unreachable!(),
}
generate_locale_detection(&mut code, base_locale);
if let Some(config) = analytics_config {
if config.enabled {
generate_analytics_methods(&mut code, config);
}
}
let base_refs: Vec<&Translation> = base_translations.to_vec();
check_identifier_collisions(&base_refs)?;
generate_flat_methods_with_mode(
&mut code,
&base_translations,
base_locale,
analytics_config,
mode,
);
generate_namespace_structure(&mut code, &base_translations);
code.push_str("return Translations\n");
Ok(format_generated_luau(&code))
}
fn generate_locale_detection(code: &mut String, base_locale: &str) {
code.push_str("function Translations.detectLocale(player)\n");
code.push_str(" local localeId = player.LocaleId\n");
code.push_str(" if not localeId or localeId == \"\" then\n");
code.push_str(&format!(" return \"{}\"\n", base_locale));
code.push_str(" end\n");
code.push('\n');
code.push_str(" local normalized = string.lower(localeId):gsub(\"_\", \"-\")\n");
code.push_str(" local baseCode = string.match(normalized, \"^(%w+)\")\n");
code.push_str(" if baseCode == \"zh\" then\n");
code.push_str(" return normalized\n");
code.push_str(" end\n");
code.push_str(&format!(" return baseCode or \"{}\"\n", base_locale));
code.push_str("end\n\n");
code.push_str("function Translations.newForPlayer(player)\n");
code.push_str(" local locale = Translations.detectLocale(player)\n");
code.push_str(" return Translations.new(locale)\n");
code.push_str("end\n\n");
}
fn generate_analytics_methods(code: &mut String, config: &crate::config::AnalyticsConfig) {
code.push_str("function Translations:_trackMissing(key)\n");
code.push_str(" if not self._analytics_enabled or not self._track_missing then\n");
code.push_str(" return\n");
code.push_str(" end\n");
code.push('\n');
code.push_str(" if self._analytics_callback then\n");
code.push_str(" pcall(function()\n");
code.push_str(" self._analytics_callback(\"missing_translation\", {\n");
code.push_str(" key = key,\n");
code.push_str(" locale = self._locale,\n");
code.push_str(" timestamp = os.time()\n");
code.push_str(" })\n");
code.push_str(" end)\n");
code.push_str(" return\n");
code.push_str(" end\n");
code.push('\n');
code.push_str(
" warn(string.format(\"[Slang] Missing translation: %s (%s)\", key, self._locale))\n",
);
code.push_str("end\n\n");
if config.track_usage {
code.push_str("function Translations:_trackUsage(key)\n");
code.push_str(" if not self._analytics_enabled or not self._track_usage then\n");
code.push_str(" return\n");
code.push_str(" end\n");
code.push('\n');
code.push_str(" self._usage_stats[key] = (self._usage_stats[key] or 0) + 1\n");
code.push_str("end\n\n");
code.push_str("function Translations:getUsageStats()\n");
code.push_str(" return self._usage_stats\n");
code.push_str("end\n\n");
}
}
fn generate_plural_method_embedded(
code: &mut String,
base_key: &str,
_translations: &[&Translation],
base_locale: &str,
) {
let method_name = sanitize_luau_identifier(base_key);
code.push_str(&format!(
"function Translations:{}(count, params)\n",
method_name
));
code.push_str(" params = params or {}\n");
code.push_str(" params.count = count\n");
code.push('\n');
code.push_str(" local category = \"other\"\n");
code.push('\n');
code.push_str(" if self._locale == \"en\" then\n");
code.push_str(" if count == 1 then\n");
code.push_str(" category = \"one\"\n");
code.push_str(" end\n");
code.push_str(" elseif self._locale == \"ru\" or self._locale == \"uk\" then\n");
code.push_str(" local mod10 = math.abs(count) % 10\n");
code.push_str(" local mod100 = math.abs(count) % 100\n");
code.push_str(" if mod10 == 1 and mod100 ~= 11 then\n");
code.push_str(" category = \"one\"\n");
code.push_str(
" elseif mod10 >= 2 and mod10 <= 4 and (mod100 < 12 or mod100 > 14) then\n",
);
code.push_str(" category = \"few\"\n");
code.push_str(" else\n");
code.push_str(" category = \"many\"\n");
code.push_str(" end\n");
code.push_str(" elseif self._locale == \"ar\" then\n");
code.push_str(" local absCount = math.abs(count)\n");
code.push_str(" local mod100 = absCount % 100\n");
code.push_str(" if absCount == 0 then\n");
code.push_str(" category = \"zero\"\n");
code.push_str(" elseif absCount == 1 then\n");
code.push_str(" category = \"one\"\n");
code.push_str(" elseif absCount == 2 then\n");
code.push_str(" category = \"two\"\n");
code.push_str(" elseif mod100 >= 3 and mod100 <= 10 then\n");
code.push_str(" category = \"few\"\n");
code.push_str(" elseif mod100 >= 11 and mod100 <= 99 then\n");
code.push_str(" category = \"many\"\n");
code.push_str(" end\n");
code.push_str(" end\n");
code.push('\n');
code.push_str(&format!(
" local locale_data = EMBEDDED_TRANSLATIONS[self._locale] or EMBEDDED_TRANSLATIONS[\"{}\"]\n",
base_locale
));
code.push_str(&format!(
" local key = \"{}(\" .. category .. \")\"\n",
base_key
));
code.push_str(" local template = locale_data[key]\n");
code.push('\n');
code.push_str(" if not template then\n");
code.push_str(&format!(
" template = locale_data[\"{}(other)\"] or key\n",
base_key
));
code.push_str(" end\n");
code.push('\n');
code.push_str(" local result = template\n");
code.push_str(" for paramKey, value in pairs(params) do\n");
code.push_str(" result = result:gsub(\"{\" .. paramKey .. \"}\", tostring(value))\n");
code.push_str(" end\n");
code.push('\n');
code.push_str(" return result\n");
code.push_str("end\n\n");
}
fn generate_plural_method_cloud(code: &mut String, base_key: &str, _translations: &[&Translation]) {
let method_name = sanitize_luau_identifier(base_key);
code.push_str(&format!(
"function Translations:{}(count, params)\n",
method_name
));
code.push_str(" params = params or {}\n");
code.push_str(" params.count = count\n");
code.push('\n');
code.push_str(" local category = \"other\"\n");
code.push('\n');
code.push_str(" if self._locale == \"en\" then\n");
code.push_str(" if count == 1 then\n");
code.push_str(" category = \"one\"\n");
code.push_str(" end\n");
code.push_str(" elseif self._locale == \"ru\" or self._locale == \"uk\" then\n");
code.push_str(" local mod10 = math.abs(count) % 10\n");
code.push_str(" local mod100 = math.abs(count) % 100\n");
code.push_str(" if mod10 == 1 and mod100 ~= 11 then\n");
code.push_str(" category = \"one\"\n");
code.push_str(
" elseif mod10 >= 2 and mod10 <= 4 and (mod100 < 12 or mod100 > 14) then\n",
);
code.push_str(" category = \"few\"\n");
code.push_str(" else\n");
code.push_str(" category = \"many\"\n");
code.push_str(" end\n");
code.push_str(" elseif self._locale == \"ar\" then\n");
code.push_str(" local absCount = math.abs(count)\n");
code.push_str(" local mod100 = absCount % 100\n");
code.push_str(" if absCount == 0 then\n");
code.push_str(" category = \"zero\"\n");
code.push_str(" elseif absCount == 1 then\n");
code.push_str(" category = \"one\"\n");
code.push_str(" elseif absCount == 2 then\n");
code.push_str(" category = \"two\"\n");
code.push_str(" elseif mod100 >= 3 and mod100 <= 10 then\n");
code.push_str(" category = \"few\"\n");
code.push_str(" elseif mod100 >= 11 and mod100 <= 99 then\n");
code.push_str(" category = \"many\"\n");
code.push_str(" end\n");
code.push_str(" end\n");
code.push('\n');
code.push_str(&format!(
" local key = \"{}(\" .. category .. \")\"\n",
base_key
));
code.push_str(" local success, result = pcall(function()\n");
code.push_str(" return self._translator:FormatByKey(key, params)\n");
code.push_str(" end)\n");
code.push('\n');
code.push_str(" if success then\n");
code.push_str(" return result\n");
code.push_str(" end\n");
code.push('\n');
code.push_str(&format!(
" return self._translator:FormatByKey(\"{}(other)\", params)\n",
base_key
));
code.push_str("end\n\n");
}
fn generate_plural_method_hybrid(
code: &mut String,
base_key: &str,
_translations: &[&Translation],
_base_locale: &str,
) {
let method_name = sanitize_luau_identifier(base_key);
code.push_str(&format!(
"function Translations:{}(count, params)\n",
method_name
));
code.push_str(" params = params or {}\n");
code.push_str(" params.count = count\n");
code.push('\n');
code.push_str(" local category = \"other\"\n");
code.push('\n');
code.push_str(" if self._locale == \"en\" then\n");
code.push_str(" if count == 1 then\n");
code.push_str(" category = \"one\"\n");
code.push_str(" end\n");
code.push_str(" elseif self._locale == \"ru\" or self._locale == \"uk\" then\n");
code.push_str(" local mod10 = math.abs(count) % 10\n");
code.push_str(" local mod100 = math.abs(count) % 100\n");
code.push_str(" if mod10 == 1 and mod100 ~= 11 then\n");
code.push_str(" category = \"one\"\n");
code.push_str(
" elseif mod10 >= 2 and mod10 <= 4 and (mod100 < 12 or mod100 > 14) then\n",
);
code.push_str(" category = \"few\"\n");
code.push_str(" else\n");
code.push_str(" category = \"many\"\n");
code.push_str(" end\n");
code.push_str(" elseif self._locale == \"ar\" then\n");
code.push_str(" local absCount = math.abs(count)\n");
code.push_str(" local mod100 = absCount % 100\n");
code.push_str(" if absCount == 0 then\n");
code.push_str(" category = \"zero\"\n");
code.push_str(" elseif absCount == 1 then\n");
code.push_str(" category = \"one\"\n");
code.push_str(" elseif absCount == 2 then\n");
code.push_str(" category = \"two\"\n");
code.push_str(" elseif mod100 >= 3 and mod100 <= 10 then\n");
code.push_str(" category = \"few\"\n");
code.push_str(" elseif mod100 >= 11 and mod100 <= 99 then\n");
code.push_str(" category = \"many\"\n");
code.push_str(" end\n");
code.push_str(" end\n");
code.push('\n');
code.push_str(&format!(
" local pluralKey = \"{}(\" .. category .. \")\"\n",
base_key
));
code.push_str(" local result = self:_resolve(pluralKey, params)\n");
code.push('\n');
code.push_str(" if result == pluralKey then\n");
code.push_str(&format!(
" return self:_resolve(\"{}(other)\", params)\n",
base_key
));
code.push_str(" end\n");
code.push_str(" return result\n");
code.push_str("end\n\n");
}
fn generate_flat_methods_with_mode(
code: &mut String,
translations: &[&Translation],
base_locale: &str,
analytics_config: Option<&crate::config::AnalyticsConfig>,
mode: &str,
) {
let mut plural_groups: HashMap<String, Vec<&Translation>> = HashMap::new();
let mut regular_translations = Vec::new();
for translation in translations {
if plurals::is_plural_key(&translation.key) {
let base_key = plurals::extract_base_key(&translation.key);
plural_groups.entry(base_key).or_default().push(translation);
} else {
regular_translations.push(*translation);
}
}
regular_translations.sort_by(|a, b| a.key.cmp(&b.key));
for translation in regular_translations {
match mode {
"embedded" => {
generate_method_embedded(code, translation, base_locale, analytics_config)
}
"cloud" => generate_method_cloud(code, translation, analytics_config),
"hybrid" => generate_method_hybrid(code, translation, base_locale, analytics_config),
_ => unreachable!(
"Invalid localization mode: {}. Expected 'embedded', 'cloud', or 'hybrid'",
mode
),
}
}
let mut plural_keys_sorted: Vec<_> = plural_groups.keys().collect();
plural_keys_sorted.sort();
for base_key in plural_keys_sorted {
let plural_translations = &plural_groups[base_key];
match mode {
"embedded" => {
generate_plural_method_embedded(code, base_key, plural_translations, base_locale)
}
"cloud" => generate_plural_method_cloud(code, base_key, plural_translations),
"hybrid" => {
generate_plural_method_hybrid(code, base_key, plural_translations, base_locale)
}
_ => unreachable!(
"Invalid localization mode: {}. Expected 'embedded', 'cloud', or 'hybrid'",
mode
),
}
}
}
fn generate_namespace_structure(code: &mut String, translations: &[&Translation]) {
let mut plural_base_keys: HashSet<String> = HashSet::new();
let mut regular_translations = Vec::new();
for translation in translations {
if plurals::is_plural_key(&translation.key) {
let base_key = plurals::extract_base_key(&translation.key);
plural_base_keys.insert(base_key);
} else {
regular_translations.push(*translation);
}
}
regular_translations.sort_by(|a, b| a.key.cmp(&b.key));
let mut all_namespaces: HashSet<String> = HashSet::new();
for translation in ®ular_translations {
for namespace in sanitized_namespace_prefixes(&translation.key) {
all_namespaces.insert(namespace);
}
}
for base_key in &plural_base_keys {
for namespace in sanitized_namespace_prefixes(base_key) {
all_namespaces.insert(namespace);
}
}
if all_namespaces.is_empty() {
code.push_str("function Translations._setupNamespaces(_self) end\n\n");
return;
}
code.push_str("function Translations._setupNamespaces(self)\n");
let mut sorted_namespaces: Vec<_> = all_namespaces.into_iter().collect();
sort_namespace_paths(&mut sorted_namespaces);
for namespace in &sorted_namespaces {
code.push_str(&format!(" self.{} = {{}}\n", namespace));
}
code.push('\n');
for translation in ®ular_translations {
let parts: Vec<&str> = translation.key.split('.').collect();
if parts.len() <= 1 {
continue;
}
let namespace = sanitize_namespace_path(&parts[0..parts.len() - 1].join("."));
let method = sanitize_segment(parts[parts.len() - 1]);
let flat_method = sanitize_luau_identifier(&translation.key);
let params_with_format = format::extract_parameters_with_format(&translation.value);
if !params_with_format.is_empty() {
code.push_str(&format!(
" self.{}.{} = function(_, ...)\n",
namespace, method
));
code.push_str(&format!(" return self:{}(...)\n", flat_method));
} else {
code.push_str(&format!(" self.{}.{} = function()\n", namespace, method));
code.push_str(&format!(" return self:{}()\n", flat_method));
}
code.push_str(" end\n");
}
let mut plural_keys_sorted: Vec<_> = plural_base_keys.iter().collect();
plural_keys_sorted.sort();
for base_key in &plural_keys_sorted {
let parts: Vec<&str> = base_key.split('.').collect();
if parts.len() <= 1 {
continue;
}
let namespace = sanitize_namespace_path(&parts[0..parts.len() - 1].join("."));
let method = sanitize_segment(parts[parts.len() - 1]);
let flat_method = sanitize_luau_identifier(base_key);
code.push_str(&format!(
" self.{}.{} = function(_, ...)\n",
namespace, method
));
code.push_str(&format!(" return self:{}(...)\n", flat_method));
code.push_str(" end\n");
}
code.push_str("end\n\n");
}
pub fn extract_parameters(text: &str) -> Vec<String> {
let mut params = Vec::new();
let mut in_param = false;
let mut current_param = String::new();
for ch in text.chars() {
match ch {
'{' => {
in_param = true;
current_param.clear();
}
'}' => {
if in_param && !current_param.is_empty() {
let param_name = current_param
.split(':')
.next()
.unwrap_or(¤t_param)
.trim()
.to_string();
if !param_name.is_empty() {
params.push(param_name);
}
}
in_param = false;
}
_ => {
if in_param {
current_param.push(ch);
}
}
}
}
params
}
pub fn escape_lua_string(s: &str) -> String {
s.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('\n', "\\n")
.replace('\r', "\\r")
.replace('\t', "\\t")
}
fn generate_embedded_data(
code: &mut String,
translations: &[Translation],
supported_locales: &[String],
) {
code.push_str("local EMBEDDED_TRANSLATIONS = {\n");
for locale in supported_locales {
code.push_str(&format!(" [\"{}\"] = {{\n", locale));
let mut locale_translations: Vec<_> = translations
.iter()
.filter(|t| t.locale == *locale)
.collect();
locale_translations.sort_by(|a, b| a.key.cmp(&b.key));
for translation in locale_translations {
let escaped_value = escape_lua_string(&translation.value);
code.push_str(&format!(
" [\"{}\"] = \"{}\",\n",
translation.key, escaped_value
));
}
code.push_str(" },\n");
}
code.push_str("}\n\n");
}
fn generate_constructor_embedded(
code: &mut String,
base_locale: &str,
analytics_config: Option<&crate::config::AnalyticsConfig>,
) {
code.push_str("function Translations.new(locale)\n");
code.push_str(" local self = setmetatable({}, Translations)\n");
code.push_str(&format!(
" self._locale = locale or \"{}\"\n",
base_locale
));
code.push_str(" self._localeChangedCallbacks = {}\n");
if let Some(config) = analytics_config {
if config.enabled {
code.push('\n');
code.push_str(" self._analytics_enabled = true\n");
code.push_str(&format!(
" self._track_missing = {}\n",
config.track_missing
));
code.push_str(&format!(" self._track_usage = {}\n", config.track_usage));
code.push_str(" self._usage_stats = {}\n");
if let Some(callback_path) = &config.callback {
code.push_str(&format!(
" self._analytics_callback = require({})\n",
callback_path
));
} else {
code.push_str(" self._analytics_callback = nil\n");
}
}
}
code.push('\n');
code.push_str(" Translations._setupNamespaces(self)\n");
code.push_str(" return self\n");
code.push_str("end\n\n");
code.push_str("function Translations:setLocale(locale)\n");
code.push_str(" if self._locale == locale then\n");
code.push_str(" return\n");
code.push_str(" end\n");
code.push('\n');
code.push_str(" local oldLocale = self._locale\n");
code.push_str(" self._locale = locale\n");
code.push('\n');
code.push_str(" for _, callback in ipairs(self._localeChangedCallbacks) do\n");
code.push_str(" task.spawn(callback, locale, oldLocale)\n");
code.push_str(" end\n");
code.push_str("end\n\n");
code.push_str("function Translations:getLocale()\n");
code.push_str(" return self._locale\n");
code.push_str("end\n\n");
code.push_str("function Translations:onLocaleChanged(callback)\n");
code.push_str(" table.insert(self._localeChangedCallbacks, callback)\n");
code.push_str("end\n\n");
}
fn generate_constructor_cloud(
code: &mut String,
base_locale: &str,
analytics_config: Option<&crate::config::AnalyticsConfig>,
) {
code.push_str("function Translations.new(locale)\n");
code.push_str(" local self = setmetatable({}, Translations)\n");
code.push_str(&format!(
" self._locale = locale or \"{}\"\n",
base_locale
));
code.push_str(" self._localeChangedCallbacks = {}\n");
if let Some(config) = analytics_config {
if config.enabled {
code.push('\n');
code.push_str(" self._analytics_enabled = true\n");
code.push_str(&format!(
" self._track_missing = {}\n",
config.track_missing
));
code.push_str(&format!(" self._track_usage = {}\n", config.track_usage));
code.push_str(" self._usage_stats = {}\n");
if let Some(callback_path) = &config.callback {
code.push_str(&format!(
" self._analytics_callback = require({})\n",
callback_path
));
} else {
code.push_str(" self._analytics_callback = nil\n");
}
}
}
code.push('\n');
code.push_str(" local LocalizationService = game:GetService(\"LocalizationService\")\n");
code.push_str(" local success, translator = pcall(function()\n");
code.push_str(" return LocalizationService:GetTranslatorForLocaleAsync(self._locale)\n");
code.push_str(" end)\n");
code.push('\n');
code.push_str(" if not success then\n");
code.push_str(" error(\n");
code.push_str(" \"Failed to get translator for locale: \"\n");
code.push_str(" .. self._locale\n");
code.push_str(" .. \"\\n\"\n");
code.push_str(
" .. \"\\nHint: Make sure you've uploaded translations to Roblox Cloud.\"\n",
);
code.push_str(" .. \"\\nRun: roblox-slang upload --table-id YOUR_TABLE_ID\"\n");
code.push_str(" )\n");
code.push_str(" end\n");
code.push('\n');
code.push_str(" self._translator = translator\n");
code.push('\n');
code.push_str(" Translations._setupNamespaces(self)\n");
code.push_str(" return self\n");
code.push_str("end\n\n");
code.push_str("function Translations:setLocale(locale)\n");
code.push_str(" if self._locale == locale then\n");
code.push_str(" return\n");
code.push_str(" end\n");
code.push('\n');
code.push_str(" local oldLocale = self._locale\n");
code.push_str(" self._locale = locale\n");
code.push('\n');
code.push_str(" local LocalizationService = game:GetService(\"LocalizationService\")\n");
code.push_str(" local success, translator = pcall(function()\n");
code.push_str(" return LocalizationService:GetTranslatorForLocaleAsync(locale)\n");
code.push_str(" end)\n");
code.push('\n');
code.push_str(" if success then\n");
code.push_str(" self._translator = translator\n");
code.push_str(" else\n");
code.push_str(" warn(\"Failed to switch to locale: \" .. locale)\n");
code.push_str(" self._locale = oldLocale\n");
code.push_str(" return\n");
code.push_str(" end\n");
code.push('\n');
code.push_str(" for _, callback in ipairs(self._localeChangedCallbacks) do\n");
code.push_str(" task.spawn(callback, locale, oldLocale)\n");
code.push_str(" end\n");
code.push_str("end\n\n");
code.push_str("function Translations:getLocale()\n");
code.push_str(" return self._locale\n");
code.push_str("end\n\n");
code.push_str("function Translations:onLocaleChanged(callback)\n");
code.push_str(" table.insert(self._localeChangedCallbacks, callback)\n");
code.push_str("end\n\n");
code.push_str("function Translations:getAsset(assetKey)\n");
code.push_str(" local key = \"assets.\" .. assetKey .. \".\" .. self._locale\n");
code.push_str(" local success, result = pcall(function()\n");
code.push_str(" return self._translator:FormatByKey(key)\n");
code.push_str(" end)\n");
code.push('\n');
code.push_str(" if success then\n");
code.push_str(" return result\n");
code.push_str(" end\n");
code.push_str(&format!(
" local fallbackKey = \"assets.\" .. assetKey .. \".{}\"\n",
base_locale
));
code.push_str(" return self._translator:FormatByKey(fallbackKey)\n");
code.push_str("end\n\n");
}
fn generate_constructor_hybrid(
code: &mut String,
base_locale: &str,
analytics_config: Option<&crate::config::AnalyticsConfig>,
) {
code.push_str("function Translations.new(locale)\n");
code.push_str(" local self = setmetatable({}, Translations)\n");
code.push_str(&format!(
" self._locale = locale or \"{}\"\n",
base_locale
));
code.push_str(" self._localeChangedCallbacks = {}\n");
if let Some(config) = analytics_config {
if config.enabled {
code.push('\n');
code.push_str(" self._analytics_enabled = true\n");
code.push_str(&format!(
" self._track_missing = {}\n",
config.track_missing
));
code.push_str(&format!(" self._track_usage = {}\n", config.track_usage));
code.push_str(" self._usage_stats = {}\n");
if let Some(callback_path) = &config.callback {
code.push_str(&format!(
" self._analytics_callback = require({})\n",
callback_path
));
} else {
code.push_str(" self._analytics_callback = nil\n");
}
}
}
code.push('\n');
code.push_str(" local LocalizationService = game:GetService(\"LocalizationService\")\n");
code.push_str(" local success, translator = pcall(function()\n");
code.push_str(" return LocalizationService:GetTranslatorForLocaleAsync(self._locale)\n");
code.push_str(" end)\n");
code.push('\n');
code.push_str(" if success then\n");
code.push_str(" self._translator = translator\n");
code.push_str(" else\n");
code.push_str(
" warn(\"LocalizationService unavailable, using embedded translations\")\n",
);
code.push_str(" self._translator = nil\n");
code.push_str(" end\n");
code.push('\n');
code.push_str(" Translations._setupNamespaces(self)\n");
code.push_str(" return self\n");
code.push_str("end\n\n");
code.push_str("function Translations:setLocale(locale)\n");
code.push_str(" if self._locale == locale then\n");
code.push_str(" return\n");
code.push_str(" end\n");
code.push('\n');
code.push_str(" local oldLocale = self._locale\n");
code.push_str(" self._locale = locale\n");
code.push('\n');
code.push_str(" local LocalizationService = game:GetService(\"LocalizationService\")\n");
code.push_str(" local success, translator = pcall(function()\n");
code.push_str(" return LocalizationService:GetTranslatorForLocaleAsync(locale)\n");
code.push_str(" end)\n");
code.push('\n');
code.push_str(" if success then\n");
code.push_str(" self._translator = translator\n");
code.push_str(" else\n");
code.push_str(" self._translator = nil\n");
code.push_str(" end\n");
code.push('\n');
code.push_str(" for _, callback in ipairs(self._localeChangedCallbacks) do\n");
code.push_str(" task.spawn(callback, locale, oldLocale)\n");
code.push_str(" end\n");
code.push_str("end\n\n");
code.push_str("function Translations:getLocale()\n");
code.push_str(" return self._locale\n");
code.push_str("end\n\n");
code.push_str("function Translations:onLocaleChanged(callback)\n");
code.push_str(" table.insert(self._localeChangedCallbacks, callback)\n");
code.push_str("end\n\n");
code.push_str("function Translations:_resolve(key, params)\n");
code.push_str(" if self._translator then\n");
code.push_str(" local success, result = pcall(function()\n");
code.push_str(" if params then\n");
code.push_str(" return self._translator:FormatByKey(key, params)\n");
code.push_str(" else\n");
code.push_str(" return self._translator:FormatByKey(key)\n");
code.push_str(" end\n");
code.push_str(" end)\n");
code.push_str(" if success and result ~= \"\" then\n");
code.push_str(" return result\n");
code.push_str(" end\n");
code.push_str(" end\n");
code.push('\n');
code.push_str(&format!(
" local locale_data = EMBEDDED_TRANSLATIONS[self._locale] or EMBEDDED_TRANSLATIONS[\"{}\"]\n",
base_locale
));
code.push_str(" if params then\n");
code.push_str(" local template = locale_data[key] or key\n");
code.push_str(" local result = template\n");
code.push_str(" for paramKey, value in pairs(params) do\n");
code.push_str(
" result = result:gsub(\"{\" .. paramKey .. \"}\", tostring(value))\n",
);
code.push_str(" end\n");
code.push_str(" return result\n");
code.push_str(" else\n");
code.push_str(" return locale_data[key] or key\n");
code.push_str(" end\n");
code.push_str("end\n\n");
}
fn generate_method_embedded(
code: &mut String,
translation: &Translation,
base_locale: &str,
analytics_config: Option<&crate::config::AnalyticsConfig>,
) {
let method_name = sanitize_luau_identifier(&translation.key);
let params_with_format = format::extract_parameters_with_format(&translation.value);
let analytics_enabled = analytics_config.map(|c| c.enabled).unwrap_or(false);
let track_usage = analytics_config.map(|c| c.track_usage).unwrap_or(false);
let track_missing = analytics_config.map(|c| c.track_missing).unwrap_or(false);
if !params_with_format.is_empty() {
code.push_str(&format!("function Translations:{}(params)\n", method_name));
code.push_str(" params = params or {}\n");
if analytics_enabled && track_usage {
code.push_str(&format!(" self:_trackUsage(\"{}\")\n", translation.key));
}
for (param_name, specifier) in ¶ms_with_format {
if *specifier != format::FormatSpecifier::None {
let format_code = format::generate_format_code(param_name, specifier);
if !format_code.is_empty() {
code.push_str(" ");
code.push_str(&format_code);
code.push('\n');
}
}
}
code.push_str(&format!(
" local locale_data = EMBEDDED_TRANSLATIONS[self._locale] or EMBEDDED_TRANSLATIONS[\"{}\"]\n",
base_locale
));
code.push_str(&format!(
" local template = locale_data[\"{}\"] or \"{}\"\n",
translation.key, translation.key
));
if analytics_enabled && track_missing {
code.push_str(&format!(
" if not locale_data[\"{}\"] then\n",
translation.key
));
code.push_str(&format!(
" self:_trackMissing(\"{}\")\n",
translation.key
));
code.push_str(" end\n");
}
code.push('\n');
code.push_str(" local result = template\n");
code.push_str(" for paramKey, value in pairs(params) do\n");
code.push_str(
" result = result:gsub(\"{\" .. paramKey .. \"}\", tostring(value))\n",
);
code.push_str(" end\n");
code.push('\n');
code.push_str(" return result\n");
} else {
code.push_str(&format!("function Translations:{}()\n", method_name));
if analytics_enabled && track_usage {
code.push_str(&format!(" self:_trackUsage(\"{}\")\n", translation.key));
}
code.push_str(&format!(
" local locale_data = EMBEDDED_TRANSLATIONS[self._locale] or EMBEDDED_TRANSLATIONS[\"{}\"]\n",
base_locale
));
if analytics_enabled && track_missing {
code.push_str(&format!(
" if not locale_data[\"{}\"] then\n",
translation.key
));
code.push_str(&format!(
" self:_trackMissing(\"{}\")\n",
translation.key
));
code.push_str(" end\n");
}
code.push_str(&format!(
" return locale_data[\"{}\"] or \"{}\"\n",
translation.key, translation.key
));
}
code.push_str("end\n\n");
}
fn generate_method_cloud(
code: &mut String,
translation: &Translation,
analytics_config: Option<&crate::config::AnalyticsConfig>,
) {
let method_name = sanitize_luau_identifier(&translation.key);
let params_with_format = format::extract_parameters_with_format(&translation.value);
let analytics_enabled = analytics_config.map(|c| c.enabled).unwrap_or(false);
let track_usage = analytics_config.map(|c| c.track_usage).unwrap_or(false);
let track_missing = analytics_config.map(|c| c.track_missing).unwrap_or(false);
if !params_with_format.is_empty() {
code.push_str(&format!("function Translations:{}(params)\n", method_name));
code.push_str(" params = params or {}\n");
if analytics_enabled && track_usage {
code.push_str(&format!(" self:_trackUsage(\"{}\")\n", translation.key));
}
for (param_name, specifier) in ¶ms_with_format {
if *specifier != format::FormatSpecifier::None {
let format_code = format::generate_format_code(param_name, specifier);
if !format_code.is_empty() {
code.push_str(" ");
code.push_str(&format_code);
code.push('\n');
}
}
}
if analytics_enabled && track_missing {
code.push_str(&format!(
" local value = self._translator:FormatByKey(\"{}\", params)\n",
translation.key
));
code.push_str(" if value == \"\" or value == \"{}\" then\n");
code.push_str(&format!(
" self:_trackMissing(\"{}\")\n",
translation.key
));
code.push_str(&format!(" return \"{}\"\n", translation.key));
code.push_str(" end\n");
code.push_str(" return value\n");
} else {
code.push_str(&format!(
" return self._translator:FormatByKey(\"{}\", params)\n",
translation.key
));
}
} else {
code.push_str(&format!("function Translations:{}()\n", method_name));
if analytics_enabled && track_usage {
code.push_str(&format!(" self:_trackUsage(\"{}\")\n", translation.key));
}
if analytics_enabled && track_missing {
code.push_str(&format!(
" local value = self._translator:FormatByKey(\"{}\")\n",
translation.key
));
code.push_str(" if value == \"\" or value == \"{}\" then\n");
code.push_str(&format!(
" self:_trackMissing(\"{}\")\n",
translation.key
));
code.push_str(&format!(" return \"{}\"\n", translation.key));
code.push_str(" end\n");
code.push_str(" return value\n");
} else {
code.push_str(&format!(
" return self._translator:FormatByKey(\"{}\")\n",
translation.key
));
}
}
code.push_str("end\n\n");
}
fn generate_method_hybrid(
code: &mut String,
translation: &Translation,
base_locale: &str,
analytics_config: Option<&crate::config::AnalyticsConfig>,
) {
let method_name = sanitize_luau_identifier(&translation.key);
let params_with_format = format::extract_parameters_with_format(&translation.value);
let analytics_enabled = analytics_config.map(|c| c.enabled).unwrap_or(false);
let track_usage = analytics_config.map(|c| c.track_usage).unwrap_or(false);
let track_missing = analytics_config.map(|c| c.track_missing).unwrap_or(false);
if !params_with_format.is_empty() {
code.push_str(&format!("function Translations:{}(params)\n", method_name));
code.push_str(" params = params or {}\n");
if analytics_enabled && track_usage {
code.push_str(&format!(" self:_trackUsage(\"{}\")\n", translation.key));
}
for (param_name, specifier) in ¶ms_with_format {
if *specifier != format::FormatSpecifier::None {
let format_code = format::generate_format_code(param_name, specifier);
if !format_code.is_empty() {
code.push_str(" ");
code.push_str(&format_code);
code.push('\n');
}
}
}
if analytics_enabled && track_missing {
code.push_str(&format!(
" local locale_data = EMBEDDED_TRANSLATIONS[self._locale] or EMBEDDED_TRANSLATIONS[\"{}\"]\n",
base_locale
));
code.push_str(&format!(
" if not locale_data[\"{}\"] then\n",
translation.key
));
code.push_str(&format!(
" self:_trackMissing(\"{}\")\n",
translation.key
));
code.push_str(" end\n");
}
code.push_str(&format!(
" return self:_resolve(\"{}\", params)\n",
translation.key
));
} else {
code.push_str(&format!("function Translations:{}()\n", method_name));
if analytics_enabled && track_usage {
code.push_str(&format!(" self:_trackUsage(\"{}\")\n", translation.key));
}
if analytics_enabled && track_missing {
code.push_str(&format!(
" local locale_data = EMBEDDED_TRANSLATIONS[self._locale] or EMBEDDED_TRANSLATIONS[\"{}\"]\n",
base_locale
));
code.push_str(&format!(
" if not locale_data[\"{}\"] then\n",
translation.key
));
code.push_str(&format!(
" self:_trackMissing(\"{}\")\n",
translation.key
));
code.push_str(" end\n");
}
code.push_str(&format!(
" return self:_resolve(\"{}\")\n",
translation.key
));
}
code.push_str("end\n\n");
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_parameters() {
let params = extract_parameters("Hello {name}, you have {count:int} items");
assert_eq!(params, vec!["name", "count"]);
}
#[test]
fn test_extract_parameters_no_params() {
let params = extract_parameters("Hello world");
assert!(params.is_empty());
}
#[test]
fn test_escape_lua_string_basic() {
assert_eq!(escape_lua_string("Hello"), "Hello");
assert_eq!(escape_lua_string("Simple text"), "Simple text");
}
#[test]
fn test_escape_lua_string_quotes() {
assert_eq!(escape_lua_string("Hello \"World\""), "Hello \\\"World\\\"");
assert_eq!(escape_lua_string("Say \"Hi\""), "Say \\\"Hi\\\"");
}
#[test]
fn test_escape_lua_string_newlines() {
assert_eq!(escape_lua_string("Line1\nLine2"), "Line1\\nLine2");
assert_eq!(
escape_lua_string("First\nSecond\nThird"),
"First\\nSecond\\nThird"
);
}
#[test]
fn test_escape_lua_string_tabs() {
assert_eq!(escape_lua_string("Tab\there"), "Tab\\there");
assert_eq!(escape_lua_string("A\tB\tC"), "A\\tB\\tC");
}
#[test]
fn test_escape_lua_string_backslashes() {
assert_eq!(escape_lua_string("Back\\slash"), "Back\\\\slash");
assert_eq!(escape_lua_string("Path\\to\\file"), "Path\\\\to\\\\file");
}
#[test]
fn test_escape_lua_string_carriage_return() {
assert_eq!(escape_lua_string("Line1\rLine2"), "Line1\\rLine2");
}
#[test]
fn test_escape_lua_string_complex() {
let input = "He said: \"Hello\"\nNext line\tTabbed\\Backslash";
let expected = "He said: \\\"Hello\\\"\\nNext line\\tTabbed\\\\Backslash";
assert_eq!(escape_lua_string(input), expected);
}
#[test]
fn test_escape_lua_string_multiple_special_chars() {
let input = "\"Quote\"\n\\Backslash\\\t\tDouble Tab\r\nWindows Line";
let expected = "\\\"Quote\\\"\\n\\\\Backslash\\\\\\t\\tDouble Tab\\r\\nWindows Line";
assert_eq!(escape_lua_string(input), expected);
}
#[test]
fn test_generate_embedded_data_single_locale() {
let translations = vec![
Translation {
key: "ui.button".to_string(),
value: "Buy".to_string(),
locale: "en".to_string(),
context: None,
},
Translation {
key: "ui.message".to_string(),
value: "Hello".to_string(),
locale: "en".to_string(),
context: None,
},
];
let mut code = String::new();
generate_embedded_data(&mut code, &translations, &["en".to_string()]);
assert!(code.contains("local EMBEDDED_TRANSLATIONS = {"));
assert!(code.contains("[\"en\"] = {"));
assert!(code.contains("[\"ui.button\"] = \"Buy\""));
assert!(code.contains("[\"ui.message\"] = \"Hello\""));
}
#[test]
fn test_generate_embedded_data_multiple_locales() {
let translations = vec![
Translation {
key: "ui.button".to_string(),
value: "Buy".to_string(),
locale: "en".to_string(),
context: None,
},
Translation {
key: "ui.button".to_string(),
value: "Beli".to_string(),
locale: "id".to_string(),
context: None,
},
Translation {
key: "ui.button".to_string(),
value: "Comprar".to_string(),
locale: "es".to_string(),
context: None,
},
];
let mut code = String::new();
generate_embedded_data(
&mut code,
&translations,
&["en".to_string(), "id".to_string(), "es".to_string()],
);
assert!(code.contains("[\"en\"] = {"));
assert!(code.contains("[\"id\"] = {"));
assert!(code.contains("[\"es\"] = {"));
assert!(code.contains("[\"ui.button\"] = \"Buy\""));
assert!(code.contains("[\"ui.button\"] = \"Beli\""));
assert!(code.contains("[\"ui.button\"] = \"Comprar\""));
}
#[test]
fn test_generate_embedded_data_with_special_chars() {
let translations = vec![Translation {
key: "ui.message".to_string(),
value: "Hello \"World\"\nNew line".to_string(),
locale: "en".to_string(),
context: None,
}];
let mut code = String::new();
generate_embedded_data(&mut code, &translations, &["en".to_string()]);
assert!(code.contains("Hello \\\"World\\\"\\nNew line"));
}
#[test]
fn test_generate_embedded_data_sorted_keys() {
let translations = vec![
Translation {
key: "ui.zebra".to_string(),
value: "Z".to_string(),
locale: "en".to_string(),
context: None,
},
Translation {
key: "ui.apple".to_string(),
value: "A".to_string(),
locale: "en".to_string(),
context: None,
},
Translation {
key: "ui.banana".to_string(),
value: "B".to_string(),
locale: "en".to_string(),
context: None,
},
];
let mut code = String::new();
generate_embedded_data(&mut code, &translations, &["en".to_string()]);
let apple_pos = code.find("ui.apple").unwrap();
let banana_pos = code.find("ui.banana").unwrap();
let zebra_pos = code.find("ui.zebra").unwrap();
assert!(apple_pos < banana_pos);
assert!(banana_pos < zebra_pos);
}
#[test]
fn test_generate_embedded_data_empty_locale() {
let translations = vec![];
let mut code = String::new();
generate_embedded_data(&mut code, &translations, &["en".to_string()]);
assert!(code.contains("local EMBEDDED_TRANSLATIONS = {"));
assert!(code.contains("[\"en\"] = {"));
}
#[test]
fn test_generate_constructor_embedded_no_localization_service() {
let mut code = String::new();
generate_constructor_embedded(&mut code, "en", None);
assert!(!code.contains("LocalizationService"));
assert!(!code.contains("GetTranslatorForLocaleAsync"));
assert!(!code.contains("_translator"));
assert!(code.contains("function Translations.new(locale)"));
assert!(code.contains("self._locale = locale or \"en\""));
assert!(code.contains("self._localeChangedCallbacks = {}"));
assert!(code.contains("function Translations:setLocale(locale)"));
assert!(code.contains("function Translations:getLocale()"));
assert!(code.contains("function Translations:onLocaleChanged(callback)"));
}
#[test]
fn test_generate_constructor_cloud_has_localization_service() {
let mut code = String::new();
generate_constructor_cloud(&mut code, "en", None);
assert!(code.contains("LocalizationService"));
assert!(code.contains("GetTranslatorForLocaleAsync"));
assert!(code.contains("self._translator = translator"));
assert!(code.contains("if not success then"));
assert!(code.contains("error("));
assert!(code.contains("Make sure you've uploaded translations"));
assert!(code.contains("function Translations:getAsset(assetKey)"));
}
#[test]
fn test_generate_constructor_hybrid_optional_cloud() {
let mut code = String::new();
generate_constructor_hybrid(&mut code, "en", None);
assert!(code.contains("LocalizationService"));
assert!(code.contains("GetTranslatorForLocaleAsync"));
assert!(code.contains("pcall(function()"));
assert!(code.contains("if success then"));
assert!(code.contains("self._translator = translator"));
assert!(code.contains("else"));
assert!(code.contains("warn(\"LocalizationService unavailable"));
assert!(code.contains("self._translator = nil"));
assert!(!code.contains("error("));
}
#[test]
fn test_generate_constructor_embedded_with_analytics() {
use crate::config::AnalyticsConfig;
let analytics_config = AnalyticsConfig {
enabled: true,
track_missing: true,
track_usage: true,
callback: None,
};
let mut code = String::new();
generate_constructor_embedded(&mut code, "en", Some(&analytics_config));
assert!(code.contains("self._analytics_enabled = true"));
assert!(code.contains("self._track_missing = true"));
assert!(code.contains("self._track_usage = true"));
assert!(code.contains("self._usage_stats = {}"));
assert!(!code.contains("LocalizationService"));
}
#[test]
fn test_generate_constructor_cloud_with_analytics() {
use crate::config::AnalyticsConfig;
let analytics_config = AnalyticsConfig {
enabled: true,
track_missing: false,
track_usage: true,
callback: Some("game.Analytics".to_string()),
};
let mut code = String::new();
generate_constructor_cloud(&mut code, "en", Some(&analytics_config));
assert!(code.contains("self._analytics_enabled = true"));
assert!(code.contains("self._analytics_callback = require(game.Analytics)"));
assert!(code.contains("LocalizationService"));
}
#[test]
fn test_generate_constructor_hybrid_with_analytics() {
use crate::config::AnalyticsConfig;
let analytics_config = AnalyticsConfig {
enabled: true,
track_missing: true,
track_usage: false,
callback: None,
};
let mut code = String::new();
generate_constructor_hybrid(&mut code, "en", Some(&analytics_config));
assert!(code.contains("self._analytics_enabled = true"));
assert!(code.contains("self._track_missing = true"));
assert!(code.contains("pcall(function()"));
assert!(code.contains("self._translator = nil"));
}
#[cfg(test)]
mod property_tests {
use super::*;
use quickcheck::{quickcheck, TestResult};
#[test]
fn prop_escape_no_unescaped_special_chars() {
fn property(s: String) -> TestResult {
let escaped = escape_lua_string(&s);
let has_unescaped_newline = escaped.contains('\n');
let has_unescaped_tab = escaped.contains('\t');
let has_unescaped_carriage = escaped.contains('\r');
let has_unescaped_quote = escaped.contains('"') && !escaped.contains("\\\"");
if has_unescaped_newline
|| has_unescaped_tab
|| has_unescaped_carriage
|| has_unescaped_quote
{
return TestResult::failed();
}
TestResult::passed()
}
quickcheck(property as fn(String) -> TestResult);
}
#[test]
fn prop_escape_idempotent_on_safe_strings() {
fn property(s: String) -> TestResult {
if s.contains('\\')
|| s.contains('"')
|| s.contains('\n')
|| s.contains('\r')
|| s.contains('\t')
{
return TestResult::discard();
}
let escaped = escape_lua_string(&s);
TestResult::from_bool(escaped == s)
}
quickcheck(property as fn(String) -> TestResult);
}
#[test]
fn prop_escape_length_increases_or_stays_same() {
fn property(s: String) -> bool {
let escaped = escape_lua_string(&s);
escaped.len() >= s.len()
}
quickcheck(property as fn(String) -> bool);
}
#[test]
fn prop_escape_preserves_safe_characters() {
fn property(s: String) -> TestResult {
if !s.chars().all(|c| c.is_alphanumeric() || c == ' ') {
return TestResult::discard();
}
let escaped = escape_lua_string(&s);
TestResult::from_bool(escaped == s)
}
quickcheck(property as fn(String) -> TestResult);
}
#[test]
fn prop_embedded_data_completeness() {
fn property(keys: Vec<String>, values: Vec<String>, locale: String) -> TestResult {
if keys.is_empty() || values.is_empty() || locale.is_empty() {
return TestResult::discard();
}
let valid_keys: Vec<String> = keys
.into_iter()
.filter(|k| !k.is_empty() && k.chars().all(|c| c.is_alphanumeric() || c == '.'))
.collect();
if valid_keys.is_empty() || !locale.chars().all(|c| c.is_alphanumeric() || c == '-')
{
return TestResult::discard();
}
let translations: Vec<Translation> = valid_keys
.iter()
.zip(values.iter().cycle())
.map(|(key, value)| Translation {
key: key.clone(),
value: value.clone(),
locale: locale.clone(),
context: None,
})
.collect();
let mut code = String::new();
generate_embedded_data(&mut code, &translations, std::slice::from_ref(&locale));
for translation in &translations {
let key_pattern = format!("[\"{}\"]", translation.key);
if !code.contains(&key_pattern) {
return TestResult::failed();
}
}
TestResult::passed()
}
quickcheck(property as fn(Vec<String>, Vec<String>, String) -> TestResult);
}
#[test]
fn prop_embedded_data_all_locales_present() {
fn property(locales: Vec<String>) -> TestResult {
if locales.is_empty() {
return TestResult::discard();
}
let valid_locales: Vec<String> = locales
.into_iter()
.filter(|l| !l.is_empty() && l.chars().all(|c| c.is_alphanumeric() || c == '-'))
.collect();
if valid_locales.is_empty() {
return TestResult::discard();
}
let translations = vec![];
let mut code = String::new();
generate_embedded_data(&mut code, &translations, &valid_locales);
for locale in &valid_locales {
let locale_pattern = format!("[\"{}\"] = {{", locale);
if !code.contains(&locale_pattern) {
return TestResult::failed();
}
}
TestResult::passed()
}
quickcheck(property as fn(Vec<String>) -> TestResult);
}
}
#[test]
fn test_generate_constructor_cloud_server_safe() {
let mut code = String::new();
generate_constructor_cloud(&mut code, "id", None);
assert!(!code.contains("game.Players.LocalPlayer"));
assert!(code.contains("LocalizationService:GetTranslatorForLocaleAsync(self._locale)"));
}
#[test]
fn test_generate_namespace_with_plurals() {
let translations = [
Translation {
key: "ui.messages.items(one)".to_string(),
value: "{count} item".to_string(),
locale: "en".to_string(),
context: None,
},
Translation {
key: "ui.messages.items(other)".to_string(),
value: "{count} items".to_string(),
locale: "en".to_string(),
context: None,
},
];
let refs: Vec<_> = translations.iter().collect();
let mut code = String::new();
generate_namespace_structure(&mut code, &refs);
assert!(
code.contains("function Translations._setupNamespaces(self)"),
"must define _setupNamespaces"
);
let count = code.matches("self.ui.messages.items = function").count();
assert_eq!(count, 1, "expected one namespace closure for plural");
assert!(code.contains("self.ui.messages.items = function(_, ...)"));
assert!(!code.contains("items(one)"));
assert!(!code.contains("items(other)"));
}
#[test]
fn test_generate_flat_methods_with_plurals() {
let translations = [
Translation {
key: "ui.messages.items(one)".to_string(),
value: "{count} item".to_string(),
locale: "en".to_string(),
context: None,
},
Translation {
key: "ui.messages.items(other)".to_string(),
value: "{count} items".to_string(),
locale: "en".to_string(),
context: None,
},
];
let refs: Vec<_> = translations.iter().collect();
let mut code = String::new();
generate_flat_methods_with_mode(&mut code, &refs, "en", None, "cloud");
let count = code
.matches("function Translations:ui_messages_items")
.count();
assert_eq!(count, 1, "expected one flat method for plural");
assert!(code.contains("function Translations:ui_messages_items(count, params)"));
assert!(code.contains("local category = \"other\""));
}
#[test]
fn test_generate_luau_complete() {
let translations = vec![
Translation {
key: "ui.button".to_string(),
value: "Buy".to_string(),
locale: "en".to_string(),
context: None,
},
Translation {
key: "ui.messages.items(one)".to_string(),
value: "{count} item".to_string(),
locale: "en".to_string(),
context: None,
},
Translation {
key: "ui.messages.items(other)".to_string(),
value: "{count} items".to_string(),
locale: "en".to_string(),
context: None,
},
];
let code = generate_luau(&translations, "en").unwrap();
assert!(code.contains("Generated by roblox-slang. DO NOT MODIFY BY HAND!"));
assert!(code.contains("function Translations.new(locale)"));
assert!(!code.contains("game.Players.LocalPlayer"));
assert!(code.contains("function Translations:ui_button()"));
assert!(code.contains("function Translations:ui_messages_items(count, params)"));
assert!(code.contains("function Translations._setupNamespaces(self)"));
assert!(code.contains("self.ui = {}"));
assert!(code.contains("self.ui.button = function()"));
assert!(code.contains("self.ui.messages.items = function(_, ...)"));
assert!(code.contains("return Translations"));
}
#[test]
fn test_generate_with_format_specifiers() {
let translations = [
Translation {
key: "ui.price".to_string(),
value: "Price: ${price:fixed(2)}".to_string(),
locale: "en".to_string(),
context: None,
},
Translation {
key: "ui.score".to_string(),
value: "Score: {score:int}".to_string(),
locale: "en".to_string(),
context: None,
},
];
let refs: Vec<_> = translations.iter().collect();
let mut code = String::new();
generate_flat_methods_with_mode(&mut code, &refs, "en", None, "cloud");
assert!(code.contains("string.format(\"%.2f\""));
assert!(code.contains("math.floor"));
}
}
#[test]
fn test_analytics_generation() {
use crate::config::AnalyticsConfig;
let translations = vec![Translation {
key: "test.key".to_string(),
value: "Test Value".to_string(),
locale: "en".to_string(),
context: None,
}];
let analytics_config = AnalyticsConfig {
enabled: true,
track_missing: true,
track_usage: true,
callback: None,
};
let code = generate_luau_with_config(&translations, "en", Some(&analytics_config)).unwrap();
assert!(code.contains("self._analytics_enabled = true"));
assert!(code.contains("self._track_missing = true"));
assert!(code.contains("self._track_usage = true"));
assert!(code.contains("self._usage_stats = {}"));
assert!(code.contains("function Translations:_trackMissing(key)"));
assert!(code.contains("function Translations:_trackUsage(key)"));
assert!(code.contains("function Translations:getUsageStats()"));
assert!(code.contains("self:_trackUsage(\"test.key\")"));
assert!(code.contains("self:_trackMissing(\"test.key\")"));
}
#[test]
fn test_analytics_disabled() {
let translations = vec![Translation {
key: "test.key".to_string(),
value: "Test Value".to_string(),
locale: "en".to_string(),
context: None,
}];
let code = generate_luau(&translations, "en").unwrap();
assert!(!code.contains("self._analytics_enabled"));
assert!(!code.contains("function Translations:_trackMissing"));
assert!(!code.contains("self:_trackUsage"));
}
#[test]
fn test_analytics_with_custom_callback() {
use crate::config::AnalyticsConfig;
let translations = vec![Translation {
key: "test.key".to_string(),
value: "Test Value".to_string(),
locale: "en".to_string(),
context: None,
}];
let analytics_config = AnalyticsConfig {
enabled: true,
track_missing: true,
track_usage: false,
callback: Some("game.ReplicatedStorage.AnalyticsHandler".to_string()),
};
let code = generate_luau_with_config(&translations, "en", Some(&analytics_config)).unwrap();
assert!(code
.contains("self._analytics_callback = require(game.ReplicatedStorage.AnalyticsHandler)"));
assert!(!code.contains("function Translations:_trackUsage"));
assert!(!code.contains("function Translations:getUsageStats"));
}
#[test]
fn test_generate_method_embedded_simple() {
let translation = Translation {
key: "ui.button".to_string(),
value: "Buy".to_string(),
locale: "en".to_string(),
context: None,
};
let mut code = String::new();
generate_method_embedded(&mut code, &translation, "en", None);
assert!(code.contains("function Translations:ui_button()"));
assert!(code.contains("EMBEDDED_TRANSLATIONS[self._locale]"));
assert!(code.contains("EMBEDDED_TRANSLATIONS[\"en\"]"));
assert!(code.contains("[\"ui.button\"]"));
assert!(!code.contains("FormatByKey"));
assert!(!code.contains("_translator"));
}
#[test]
fn test_generate_method_embedded_with_params() {
let translation = Translation {
key: "ui.greeting".to_string(),
value: "Hello, {name}!".to_string(),
locale: "en".to_string(),
context: None,
};
let mut code = String::new();
generate_method_embedded(&mut code, &translation, "en", None);
assert!(code.contains("function Translations:ui_greeting(params)"));
assert!(code.contains("params = params or {}"));
assert!(code.contains("EMBEDDED_TRANSLATIONS[self._locale]"));
assert!(code.contains("local template = locale_data[\"ui.greeting\"]"));
assert!(code.contains("for paramKey, value in pairs(params) do"));
assert!(code.contains("result:gsub(\"{\" .. paramKey .. \"}\", tostring(value))"));
}
#[test]
fn test_generate_method_cloud_simple() {
let translation = Translation {
key: "ui.button".to_string(),
value: "Buy".to_string(),
locale: "en".to_string(),
context: None,
};
let mut code = String::new();
generate_method_cloud(&mut code, &translation, None);
assert!(code.contains("function Translations:ui_button()"));
assert!(code.contains("self._translator:FormatByKey(\"ui.button\")"));
assert!(!code.contains("EMBEDDED_TRANSLATIONS"));
}
#[test]
fn test_generate_method_cloud_with_params() {
let translation = Translation {
key: "ui.greeting".to_string(),
value: "Hello, {name}!".to_string(),
locale: "en".to_string(),
context: None,
};
let mut code = String::new();
generate_method_cloud(&mut code, &translation, None);
assert!(code.contains("function Translations:ui_greeting(params)"));
assert!(code.contains("params = params or {}"));
assert!(code.contains("self._translator:FormatByKey(\"ui.greeting\", params)"));
}
#[test]
fn test_generate_method_hybrid_simple() {
let translation = Translation {
key: "ui.button".to_string(),
value: "Buy".to_string(),
locale: "en".to_string(),
context: None,
};
let mut code = String::new();
generate_method_hybrid(&mut code, &translation, "en", None);
assert!(code.contains("function Translations:ui_button()"));
assert!(code.contains("self:_resolve(\"ui.button\")"));
assert!(!code.contains("if self._translator then"));
assert!(!code.contains("pcall(function()"));
}
#[test]
fn test_generate_method_hybrid_with_params() {
let translation = Translation {
key: "ui.greeting".to_string(),
value: "Hello, {name}!".to_string(),
locale: "en".to_string(),
context: None,
};
let mut code = String::new();
generate_method_hybrid(&mut code, &translation, "en", None);
assert!(code.contains("function Translations:ui_greeting(params)"));
assert!(code.contains("self:_resolve(\"ui.greeting\", params)"));
assert!(!code.contains("self._translator:FormatByKey(\"ui.greeting\", params)"));
}
#[test]
fn test_generate_method_embedded_with_analytics() {
use crate::config::AnalyticsConfig;
let translation = Translation {
key: "ui.button".to_string(),
value: "Buy".to_string(),
locale: "en".to_string(),
context: None,
};
let analytics_config = AnalyticsConfig {
enabled: true,
track_missing: true,
track_usage: true,
callback: None,
};
let mut code = String::new();
generate_method_embedded(&mut code, &translation, "en", Some(&analytics_config));
assert!(code.contains("self:_trackUsage(\"ui.button\")"));
assert!(code.contains("self:_trackMissing(\"ui.button\")"));
}
#[test]
fn test_generate_method_cloud_with_analytics() {
use crate::config::AnalyticsConfig;
let translation = Translation {
key: "ui.button".to_string(),
value: "Buy".to_string(),
locale: "en".to_string(),
context: None,
};
let analytics_config = AnalyticsConfig {
enabled: true,
track_missing: true,
track_usage: true,
callback: None,
};
let mut code = String::new();
generate_method_cloud(&mut code, &translation, Some(&analytics_config));
assert!(code.contains("self:_trackUsage(\"ui.button\")"));
assert!(code.contains("local value = self._translator:FormatByKey"));
assert!(code.contains("if value == \"\" or value == \"{}\" then"));
assert!(code.contains("self:_trackMissing(\"ui.button\")"));
}
#[test]
fn test_generate_method_hybrid_with_analytics() {
use crate::config::AnalyticsConfig;
let translation = Translation {
key: "ui.button".to_string(),
value: "Buy".to_string(),
locale: "en".to_string(),
context: None,
};
let analytics_config = AnalyticsConfig {
enabled: true,
track_missing: true,
track_usage: true,
callback: None,
};
let mut code = String::new();
generate_method_hybrid(&mut code, &translation, "en", Some(&analytics_config));
assert!(code.contains("self:_trackUsage(\"ui.button\")"));
assert!(code.contains("self:_trackMissing(\"ui.button\")"));
}
#[test]
fn test_generate_luau_with_full_config_embedded() {
use crate::config::LocalizationConfig;
let translations = vec![Translation {
key: "test.key".to_string(),
value: "Test Value".to_string(),
locale: "en".to_string(),
context: None,
}];
let localization_config = LocalizationConfig {
mode: "embedded".to_string(),
};
let code =
generate_luau_with_full_config(&translations, "en", None, Some(&localization_config))
.unwrap();
assert!(code.contains("EMBEDDED_TRANSLATIONS"));
assert!(code.contains("[\"test.key\"] = \"Test Value\""));
assert!(!code.contains("LocalizationService"));
assert!(!code.contains("FormatByKey"));
}
#[test]
fn test_generate_luau_with_full_config_cloud() {
use crate::config::LocalizationConfig;
let translations = vec![Translation {
key: "test.key".to_string(),
value: "Test Value".to_string(),
locale: "en".to_string(),
context: None,
}];
let localization_config = LocalizationConfig {
mode: "cloud".to_string(),
};
let code =
generate_luau_with_full_config(&translations, "en", None, Some(&localization_config))
.unwrap();
assert!(!code.contains("EMBEDDED_TRANSLATIONS"));
assert!(code.contains("LocalizationService"));
assert!(code.contains("FormatByKey"));
}
#[test]
fn test_generate_luau_with_full_config_hybrid() {
use crate::config::LocalizationConfig;
let translations = vec![Translation {
key: "test.key".to_string(),
value: "Test Value".to_string(),
locale: "en".to_string(),
context: None,
}];
let localization_config = LocalizationConfig {
mode: "hybrid".to_string(),
};
let code =
generate_luau_with_full_config(&translations, "en", None, Some(&localization_config))
.unwrap();
assert!(code.contains("EMBEDDED_TRANSLATIONS"));
assert!(code.contains("LocalizationService"));
assert!(code.contains("FormatByKey"));
assert!(code.contains("pcall"));
}
#[test]
fn test_generate_luau_with_full_config_default_mode() {
let translations = vec![Translation {
key: "test.key".to_string(),
value: "Test Value".to_string(),
locale: "en".to_string(),
context: None,
}];
let code = generate_luau_with_full_config(&translations, "en", None, None).unwrap();
assert!(code.contains("EMBEDDED_TRANSLATIONS"));
assert!(!code.contains("LocalizationService"));
}
#[test]
fn test_generate_luau_with_full_config_invalid_mode_returns_error() {
use crate::config::LocalizationConfig;
let translations = vec![Translation {
key: "test.key".to_string(),
value: "Test Value".to_string(),
locale: "en".to_string(),
context: None,
}];
let localization_config = LocalizationConfig {
mode: "invalid".to_string(),
};
let result =
generate_luau_with_full_config(&translations, "en", None, Some(&localization_config));
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Invalid localization mode"));
}
#[test]
fn test_generate_luau_with_full_config_multiple_locales() {
use crate::config::LocalizationConfig;
let translations = vec![
Translation {
key: "test.key".to_string(),
value: "Test Value".to_string(),
locale: "en".to_string(),
context: None,
},
Translation {
key: "test.key".to_string(),
value: "Nilai Test".to_string(),
locale: "id".to_string(),
context: None,
},
];
let localization_config = LocalizationConfig {
mode: "embedded".to_string(),
};
let code =
generate_luau_with_full_config(&translations, "en", None, Some(&localization_config))
.unwrap();
assert!(code.contains("[\"en\"]"));
assert!(code.contains("[\"id\"]"));
assert!(code.contains("\"Test Value\""));
assert!(code.contains("\"Nilai Test\""));
}
#[test]
fn test_embedded_mode_emits_locale_detection() {
use crate::config::LocalizationConfig;
let translations = vec![Translation {
key: "greeting".to_string(),
value: "Hello".to_string(),
locale: "en".to_string(),
context: None,
}];
let localization_config = LocalizationConfig {
mode: "embedded".to_string(),
};
let code =
generate_luau_with_full_config(&translations, "en", None, Some(&localization_config))
.unwrap();
assert!(
code.contains("function Translations.detectLocale(player)"),
"embedded mode must emit detectLocale"
);
assert!(
code.contains("function Translations.newForPlayer(player)"),
"embedded mode must emit newForPlayer"
);
assert!(
code.contains("local locale = Translations.detectLocale(player)"),
"newForPlayer must call detectLocale"
);
}
#[test]
fn test_locale_detection_uses_configured_base_locale() {
let mut code = String::new();
generate_locale_detection(&mut code, "id");
assert!(
code.contains("return \"id\""),
"empty LocaleId must fall back to configured base locale"
);
assert!(
code.contains("return baseCode or \"id\""),
"baseCode fallback must use configured base locale"
);
assert!(
!code.contains("return \"en\""),
"must not contain hardcoded en fallback when base_locale is id"
);
}
#[test]
fn test_constructor_uses_configured_base_locale() {
let mut code = String::new();
generate_constructor_embedded(&mut code, "es", None);
assert!(
code.contains("self._locale = locale or \"es\""),
"embedded constructor must use configured base locale as default"
);
assert!(
!code.contains("locale or \"en\""),
"must not contain hardcoded en when base_locale is es"
);
}
#[test]
fn test_cloud_asset_fallback_uses_configured_base_locale() {
let mut code = String::new();
generate_constructor_cloud(&mut code, "pt", None);
assert!(
code.contains("local fallbackKey = \"assets.\" .. assetKey .. \".pt\""),
"cloud asset fallback must use configured base locale"
);
assert!(
!code.contains("assetKey .. \".en\""),
"cloud asset fallback must not hardcode en"
);
}
#[test]
fn test_sanitize_luau_identifier_hyphen() {
assert_eq!(sanitize_luau_identifier("buy-now"), "buy_now");
assert_eq!(
sanitize_luau_identifier("ui.buttons.buy-now"),
"ui_buttons_buy_now"
);
}
#[test]
fn test_sanitize_luau_identifier_leading_digit() {
assert_eq!(sanitize_luau_identifier("3d_view"), "_3d_view");
assert_eq!(sanitize_luau_identifier("123start"), "_123start");
}
#[test]
fn test_sanitize_luau_identifier_keyword() {
assert_eq!(sanitize_luau_identifier("return"), "_return");
assert_eq!(sanitize_luau_identifier("end"), "_end");
assert_eq!(sanitize_luau_identifier("local"), "_local");
assert_eq!(sanitize_luau_identifier("function"), "_function");
}
#[test]
fn test_sanitize_luau_identifier_non_ascii() {
assert_eq!(sanitize_luau_identifier("héllo"), "h_llo");
assert_eq!(sanitize_luau_identifier("日本語"), "___");
}
#[test]
fn test_sanitize_luau_identifier_normal_keys() {
assert_eq!(sanitize_luau_identifier("greeting"), "greeting");
assert_eq!(sanitize_luau_identifier("ui.buttons.buy"), "ui_buttons_buy");
assert_eq!(
sanitize_luau_identifier("ui.buttons.buy_now"),
"ui_buttons_buy_now"
);
}
#[test]
fn test_check_identifier_collisions_no_collision() {
let translations = [
Translation {
key: "greeting".to_string(),
value: "Hello".to_string(),
locale: "en".to_string(),
context: None,
},
Translation {
key: "farewell".to_string(),
value: "Bye".to_string(),
locale: "en".to_string(),
context: None,
},
];
let refs: Vec<&Translation> = translations.iter().collect();
assert!(check_identifier_collisions(&refs).is_ok());
}
#[test]
fn test_check_identifier_collisions_detects_collision() {
let translations = [
Translation {
key: "ui.buy-now".to_string(),
value: "Buy Now".to_string(),
locale: "en".to_string(),
context: None,
},
Translation {
key: "ui.buy_now".to_string(),
value: "Buy Now".to_string(),
locale: "en".to_string(),
context: None,
},
];
let refs: Vec<&Translation> = translations.iter().collect();
let result = check_identifier_collisions(&refs);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("collision"));
}
#[test]
fn test_namespace_setup_in_constructor() {
let translations = vec![Translation {
key: "ui.buttons.buy".to_string(),
value: "Buy".to_string(),
locale: "en".to_string(),
context: None,
}];
let code = generate_luau_with_full_config(&translations, "en", None, None).unwrap();
assert!(
code.contains("Translations._setupNamespaces(self)"),
"constructor must call _setupNamespaces"
);
}
#[test]
fn test_namespace_closures_use_discard_pattern() {
let translations = vec![Translation {
key: "ui.buttons.buy".to_string(),
value: "Buy".to_string(),
locale: "en".to_string(),
context: None,
}];
let code = generate_luau_with_full_config(&translations, "en", None, None).unwrap();
assert!(
code.contains("function Translations._setupNamespaces(self)"),
"must define _setupNamespaces as a function"
);
assert!(
code.contains("self.ui.buttons.buy = function()"),
"no-param namespace method must be a closure"
);
}
#[test]
fn test_namespace_closures_with_params() {
let translations = vec![Translation {
key: "ui.messages.greeting".to_string(),
value: "Hello {name}".to_string(),
locale: "en".to_string(),
context: None,
}];
let code = generate_luau_with_full_config(&translations, "en", None, None).unwrap();
assert!(
code.contains("self.ui.messages.greeting = function(_, ...)"),
"parameterized namespace method must use function(_, ...)"
);
assert!(
code.contains("return self:ui_messages_greeting(...)"),
"closure must forward args via ... to the flat method"
);
}
#[test]
fn test_flat_only_keys_emit_noop_setup() {
let translations = vec![Translation {
key: "greeting".to_string(),
value: "Hello".to_string(),
locale: "en".to_string(),
context: None,
}];
let code = generate_luau_with_full_config(&translations, "en", None, None).unwrap();
assert!(
code.contains("Translations._setupNamespaces(self)"),
"constructor must still call _setupNamespaces"
);
assert!(
code.contains("function Translations._setupNamespaces(_self) end"),
"flat-only keys must emit a no-op _setupNamespaces"
);
}
#[test]
fn test_namespace_sanitizes_hyphen_segments() {
let translations = vec![Translation {
key: "ui.buttons.buy-now".to_string(),
value: "Buy Now".to_string(),
locale: "en".to_string(),
context: None,
}];
let code = generate_luau_with_full_config(&translations, "en", None, None).unwrap();
assert!(
code.contains("self.ui.buttons.buy_now = function()"),
"hyphenated segment must be sanitized in namespace path"
);
assert!(
!code.contains("self.ui.buttons.buy-now"),
"raw hyphenated segment must not appear as a namespace identifier"
);
}
#[test]
fn test_namespace_sanitizes_keyword_segments() {
let translations = vec![Translation {
key: "ui.return.label".to_string(),
value: "Go Back".to_string(),
locale: "en".to_string(),
context: None,
}];
let code = generate_luau_with_full_config(&translations, "en", None, None).unwrap();
assert!(
code.contains("self.ui._return = {}"),
"keyword segment must be prefixed with underscore in namespace table"
);
assert!(
code.contains("self.ui._return.label = function()"),
"keyword segment must be sanitized in closure path"
);
}
#[test]
fn test_sanitize_segment_standalone() {
assert_eq!(sanitize_segment("buy-now"), "buy_now");
assert_eq!(sanitize_segment("return"), "_return");
assert_eq!(sanitize_segment("3d"), "_3d");
assert_eq!(sanitize_segment("normal"), "normal");
}
#[test]
fn test_sanitize_namespace_path_full() {
assert_eq!(
sanitize_namespace_path("ui.buttons.buy-now"),
"ui.buttons.buy_now"
);
assert_eq!(sanitize_namespace_path("ui.return"), "ui._return");
assert_eq!(sanitize_namespace_path("ui.3d.view"), "ui._3d.view");
}
#[test]
fn test_namespace_dedupes_sanitized_parent_paths() {
let translations = vec![
Translation {
key: "ui-menu.a.open".to_string(),
value: "Open".to_string(),
locale: "en".to_string(),
context: None,
},
Translation {
key: "ui_menu.b.close".to_string(),
value: "Close".to_string(),
locale: "en".to_string(),
context: None,
},
];
let code = generate_luau_with_full_config(&translations, "en", None, None).unwrap();
assert_eq!(
code.matches("\tself.ui_menu = {}\n").count(),
1,
"sanitized parent namespace must only be initialized once"
);
let parent_index = code.find("\tself.ui_menu = {}\n").unwrap();
let child_a_index = code.find("\tself.ui_menu.a = {}\n").unwrap();
let child_b_index = code.find("\tself.ui_menu.b = {}\n").unwrap();
assert!(
parent_index < child_a_index && parent_index < child_b_index,
"parent namespace must be initialized before sanitized child namespaces"
);
assert!(code.contains("\tself.ui_menu.a.open = function()"));
assert!(code.contains("\tself.ui_menu.b.close = function()"));
}