refactor: remove dead code (#3337)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-09 01:41:26 +02:00
1 parent a19002652b
commit 93d1f8af46
1527 files changed
+21170 -73846

No files matched your search

+4 -94
View File
@@ -41,10 +41,7 @@ use crate::tokens::{
normalize_localized_capitalization, restore_masked_tokens, should_keep_unchanged,
validate_locale_specific_localization, validate_localization,
};
use crate::ts_catalog::{
StaticTsCatalogConfig, StaticTsCatalogKind, read_static_ts_entries,
rebuild_static_ts_allow_replacing, reset_static_ts_translations,
};
use crate::ts_catalog::StaticTsCatalogKind;
const FULL_TRANSLATION_ATTEMPTS: usize = 3;
const SEGMENT_TRANSLATION_ATTEMPTS: usize = 2;
@@ -70,7 +67,6 @@ pub struct TranslateArgs {
pub enum CatalogLayout {
NestedMessages,
FlatPo,
StaticTs(StaticTsCatalogConfig),
StaticJson(StaticJsonCatalogConfig),
}
@@ -415,18 +411,11 @@ pub fn run_translation(config: &RuntimeConfig, args: &TranslateArgs) -> Result<u
match &config.catalog_layout {
CatalogLayout::NestedMessages => "nested messages.po",
CatalogLayout::FlatPo => "flat .po",
CatalogLayout::StaticTs(_) => "static TypeScript locale map",
CatalogLayout::StaticJson(_) => "weblate JSON locale catalog",
}
),
false,
);
if let CatalogLayout::StaticTs(static_config) = &config.catalog_layout {
log(
&format!("Source catalog: {}", static_config.source_path.display()),
false,
);
}
if let CatalogLayout::StaticJson(static_config) = &config.catalog_layout {
log(
&format!("Source catalog: {}", static_config.source_path.display()),
@@ -586,14 +575,9 @@ fn available_locales(config: &RuntimeConfig) -> Result<Vec<String>> {
};
stem.to_string()
}
CatalogLayout::StaticTs(_) | CatalogLayout::StaticJson(_) => {
let extension = if matches!(&config.catalog_layout, CatalogLayout::StaticJson(_)) {
"json"
} else {
"ts"
};
CatalogLayout::StaticJson(_) => {
let path = entry.path();
if path.extension().and_then(|ext| ext.to_str()) != Some(extension) {
if path.extension().and_then(|ext| ext.to_str()) != Some("json") {
continue;
}
let Some(stem) = path.file_stem().and_then(|stem| stem.to_str()) else {
@@ -1187,10 +1171,7 @@ pub fn sync_source_locale(
catalog_path: &Path,
args: &TranslateArgs,
) -> Result<LocaleResult> {
if matches!(
&config.catalog_layout,
CatalogLayout::StaticTs(_) | CatalogLayout::StaticJson(_)
) {
if matches!(&config.catalog_layout, CatalogLayout::StaticJson(_)) {
log(
&format!(
"[{SOURCE_LOCALE}] Source strings live in the static source catalog; nothing to sync"
@@ -1257,10 +1238,6 @@ fn read_catalog_entries_from_content(
};
parse_po(&content)
}
CatalogLayout::StaticTs(static_config) => {
let source_content = read_static_source_catalog(&static_config.source_path)?;
read_static_ts_entries(static_config, &source_content, content, reset)
}
CatalogLayout::StaticJson(static_config) => {
let source_content = read_static_source_catalog(&static_config.source_path)?;
read_static_json_entries(static_config, &source_content, content, reset)
@@ -1271,9 +1248,6 @@ fn read_catalog_entries_from_content(
fn reset_catalog_translations(config: &RuntimeConfig, content: &str) -> Result<String> {
match &config.catalog_layout {
CatalogLayout::NestedMessages | CatalogLayout::FlatPo => reset_po_translations(content),
CatalogLayout::StaticTs(static_config) => {
reset_static_ts_translations(static_config, content)
}
CatalogLayout::StaticJson(static_config) => {
reset_static_json_translations(static_config, content)
}
@@ -1289,10 +1263,6 @@ fn rebuild_catalog_allow_replacing(
CatalogLayout::NestedMessages | CatalogLayout::FlatPo => {
rebuild_po_allow_replacing(content, translations)
}
CatalogLayout::StaticTs(static_config) => {
let source_content = read_static_source_catalog(&static_config.source_path)?;
rebuild_static_ts_allow_replacing(static_config, &source_content, content, translations)
}
CatalogLayout::StaticJson(static_config) => {
let source_content = read_static_source_catalog(&static_config.source_path)?;
rebuild_static_json_allow_replacing(
@@ -1314,7 +1284,6 @@ fn catalog_path(config: &RuntimeConfig, locale: &str) -> PathBuf {
match &config.catalog_layout {
CatalogLayout::NestedMessages => config.locales_dir.join(locale).join("messages.po"),
CatalogLayout::FlatPo => config.locales_dir.join(format!("{locale}.po")),
CatalogLayout::StaticTs(_) => config.locales_dir.join(format!("{locale}.ts")),
CatalogLayout::StaticJson(_) => config.locales_dir.join(format!("{locale}.json")),
}
}
@@ -1440,22 +1409,6 @@ struct BatchLocalizationOutcome {
failed_entries: Vec<(Entry, String)>,
}
pub fn localize_batch<C: LocalizationClient>(
client: &C,
entries: &[Entry],
locale: &str,
prompt_guidance: &[String],
) -> Result<Vec<Translation>> {
let outcome = localize_batch_partial(client, entries, locale, prompt_guidance)?;
if let Some((entry, reason)) = outcome.failed_entries.first() {
bail!(
"Batch response failed validation for {:?}: {reason}",
entry.msgid
);
}
Ok(outcome.translations)
}
fn localize_batch_partial<C: LocalizationClient>(
client: &C,
entries: &[Entry],
@@ -2721,49 +2674,6 @@ mod tests {
assert_eq!(entries[0].msgstr, "");
}
#[test]
fn static_ts_catalog_processes_missing_entries() {
let temp = tempdir().unwrap();
let source_path = temp.path().join("SourceMessages.ts");
fs::write(
&source_path,
"export const SOURCE_MESSAGES = {\n\t'hello': 'Hello',\n\t'bye': 'Bye',\n} as const;\n",
)
.unwrap();
let locales_dir = temp.path().join("locales");
fs::create_dir_all(&locales_dir).unwrap();
let locale_path = locales_dir.join("de.ts");
fs::write(
&locale_path,
"import {defineLocaleMessages} from '../Messages';\n\nexport const DE = defineLocaleMessages({\n\t'hello': 'Hallo',\n});\n",
)
.unwrap();
let config = test_config(
temp.path(),
CatalogLayout::StaticTs(StaticTsCatalogConfig {
kind: StaticTsCatalogKind::SimpleMessages,
source_path,
source_export: "SOURCE_MESSAGES".to_string(),
locale_function: "defineLocaleMessages".to_string(),
}),
locales_dir,
);
let args = TranslateArgs {
catalog: CatalogName::Errors,
dry_run: false,
..TranslateArgs::for_test(false, None, false)
};
let plan = build_locale_plan(&config, "de", &args).unwrap();
assert_eq!(plan.pending, 1);
let result = process_locale(&config, &EchoClient, "de", &args).unwrap();
assert_eq!(result.translated, 1);
assert!(
fs::read_to_string(&locale_path)
.unwrap()
.contains("'bye': 'Bye',")
);
}
#[test]
fn process_locale_runs_strings_concurrently() {
let temp = tempdir().unwrap();
-860
View File
@@ -1,867 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use std::collections::HashMap;
use std::ops::Range;
use std::path::PathBuf;
use anyhow::{Context, Result, bail};
use crate::po::{Entry, Translation};
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum StaticTsCatalogKind {
SimpleMessages,
EmailTemplates,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct StaticTsCatalogConfig {
pub kind: StaticTsCatalogKind,
pub source_path: PathBuf,
pub source_export: String,
pub locale_function: String,
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct ObjectRange {
body: Range<usize>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct ParsedStringProperty {
key: String,
value: String,
value_range: Range<usize>,
line_number: usize,
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct ParsedObjectProperty {
key: String,
body: Range<usize>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct StaticSourceMessage {
context: String,
source: String,
line_number: usize,
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct StaticTargetCatalog {
object_body: Range<usize>,
values: HashMap<String, ParsedStringProperty>,
template_objects: HashMap<String, ParsedObjectProperty>,
}
pub fn read_static_ts_entries(
config: &StaticTsCatalogConfig,
source_content: &str,
target_content: &str,
reset: bool,
) -> Result<Vec<Entry>> {
let source_messages = parse_source_messages(config, source_content)?;
let target_catalog = parse_target_catalog(config, target_content)?;
Ok(source_messages
.into_iter()
.map(|source| {
let msgstr = if reset {
String::new()
} else {
target_catalog
.values
.get(&source.context)
.map(|target| target.value.clone())
.unwrap_or_default()
};
Entry {
comments: static_comments(config, &source.context),
references: vec![format!(
"#: {}:{}",
config.source_path.display(),
source.line_number
)],
msgctxt: Some(source.context),
msgid: source.source,
msgstr,
line_number: source.line_number,
}
})
.collect())
}
pub fn rebuild_static_ts_allow_replacing(
config: &StaticTsCatalogConfig,
source_content: &str,
target_content: &str,
translations: &[Translation],
) -> Result<String> {
let source_messages = parse_source_messages(config, source_content)?;
let target_catalog = parse_target_catalog(config, target_content)?;
let translations_by_context = translations
.iter()
.filter_map(|translation| {
translation
.msgctxt
.as_ref()
.map(|context| (context.clone(), translation))
})
.collect::<HashMap<_, _>>();
let mut replacements = Vec::new();
for (context, translation) in &translations_by_context {
if let Some(target) = target_catalog.values.get(context) {
replacements.push((
target.value_range.clone(),
escape_ts_single_quoted(&translation.msgstr),
));
}
}
match config.kind {
StaticTsCatalogKind::SimpleMessages => {
add_missing_simple_translations(
target_content,
&target_catalog,
&source_messages,
&translations_by_context,
&mut replacements,
);
}
StaticTsCatalogKind::EmailTemplates => {
add_missing_email_translations(
target_content,
&target_catalog,
&source_messages,
&translations_by_context,
&mut replacements,
)?;
}
}
apply_replacements(target_content, replacements)
}
pub fn reset_static_ts_translations(
config: &StaticTsCatalogConfig,
target_content: &str,
) -> Result<String> {
let target_catalog = parse_target_catalog(config, target_content)?;
let replacements = target_catalog
.values
.values()
.map(|target| (target.value_range.clone(), "''".to_string()))
.collect::<Vec<_>>();
apply_replacements(target_content, replacements)
}
fn static_comments(config: &StaticTsCatalogConfig, context: &str) -> Vec<String> {
match config.kind {
StaticTsCatalogKind::SimpleMessages => {
vec![format!("#. Static catalog key: {context}")]
}
StaticTsCatalogKind::EmailTemplates => {
vec![format!("#. Email template field: {context}")]
}
}
}
fn parse_source_messages(
config: &StaticTsCatalogConfig,
source_content: &str,
) -> Result<Vec<StaticSourceMessage>> {
let object = find_const_object(source_content, &config.source_export)?;
match config.kind {
StaticTsCatalogKind::SimpleMessages => {
parse_string_properties(source_content, &object.body).map(|properties| {
properties
.into_iter()
.map(|property| StaticSourceMessage {
context: property.key,
source: property.value,
line_number: property.line_number,
})
.collect()
})
}
StaticTsCatalogKind::EmailTemplates => {
let mut messages = Vec::new();
for template in parse_object_properties(source_content, &object.body)? {
for field in parse_string_properties(source_content, &template.body)? {
if field.key == "subject" || field.key == "body" {
messages.push(StaticSourceMessage {
context: format!("{}.{}", template.key, field.key),
source: field.value,
line_number: field.line_number,
});
}
}
}
Ok(messages)
}
}
}
fn parse_target_catalog(
config: &StaticTsCatalogConfig,
target_content: &str,
) -> Result<StaticTargetCatalog> {
let object = find_call_object(target_content, &config.locale_function)?;
match config.kind {
StaticTsCatalogKind::SimpleMessages => {
let values = parse_string_properties(target_content, &object.body)?
.into_iter()
.map(|property| (property.key.clone(), property))
.collect::<HashMap<_, _>>();
Ok(StaticTargetCatalog {
object_body: object.body,
values,
template_objects: HashMap::new(),
})
}
StaticTsCatalogKind::EmailTemplates => {
let mut values = HashMap::new();
let mut template_objects = HashMap::new();
for template in parse_object_properties(target_content, &object.body)? {
for field in parse_string_properties(target_content, &template.body)? {
if field.key == "subject" || field.key == "body" {
values.insert(format!("{}.{}", template.key, field.key), field);
}
}
template_objects.insert(template.key.clone(), template);
}
Ok(StaticTargetCatalog {
object_body: object.body,
values,
template_objects,
})
}
}
}
fn add_missing_simple_translations(
target_content: &str,
target_catalog: &StaticTargetCatalog,
source_messages: &[StaticSourceMessage],
translations_by_context: &HashMap<String, &Translation>,
replacements: &mut Vec<(Range<usize>, String)>,
) {
let lines = source_messages
.iter()
.filter(|source| !target_catalog.values.contains_key(&source.context))
.filter_map(|source| {
translations_by_context
.get(&source.context)
.map(|translation| {
format!(
"\t{}: {},",
escape_property_key(&source.context),
escape_ts_single_quoted(&translation.msgstr)
)
})
})
.collect::<Vec<_>>();
if !lines.is_empty() {
replacements.push(append_to_object_body(
target_content,
&target_catalog.object_body,
lines,
));
}
}
fn add_missing_email_translations(
target_content: &str,
target_catalog: &StaticTargetCatalog,
source_messages: &[StaticSourceMessage],
translations_by_context: &HashMap<String, &Translation>,
replacements: &mut Vec<(Range<usize>, String)>,
) -> Result<()> {
let source_by_context = source_messages
.iter()
.map(|source| (source.context.clone(), source.source.clone()))
.collect::<HashMap<_, _>>();
let mut source_template_order = Vec::new();
for source in source_messages {
let (template_key, _field) = split_email_context(&source.context)?;
if !source_template_order
.iter()
.any(|candidate| candidate == template_key)
{
source_template_order.push(template_key.to_string());
}
}
for template_key in &source_template_order {
if let Some(template) = target_catalog.template_objects.get(template_key) {
let lines = ["subject", "body"]
.into_iter()
.filter_map(|field| {
let context = format!("{template_key}.{field}");
if target_catalog.values.contains_key(&context) {
return None;
}
translations_by_context.get(&context).map(|translation| {
format!(
"\t\t{field}: {},",
escape_ts_single_quoted(&translation.msgstr)
)
})
})
.collect::<Vec<_>>();
if !lines.is_empty() {
replacements.push(append_to_object_body(target_content, &template.body, lines));
}
}
}
let mut template_blocks = Vec::new();
for template_key in &source_template_order {
if target_catalog.template_objects.contains_key(template_key) {
continue;
}
let subject_context = format!("{template_key}.subject");
let body_context = format!("{template_key}.body");
let has_selected_translation = translations_by_context.contains_key(&subject_context)
|| translations_by_context.contains_key(&body_context);
if !has_selected_translation {
continue;
}
let subject = translations_by_context
.get(&subject_context)
.map(|translation| translation.msgstr.as_str())
.or_else(|| source_by_context.get(&subject_context).map(String::as_str))
.with_context(|| format!("missing source subject for email template {template_key}"))?;
let body = translations_by_context
.get(&body_context)
.map(|translation| translation.msgstr.as_str())
.or_else(|| source_by_context.get(&body_context).map(String::as_str))
.with_context(|| format!("missing source body for email template {template_key}"))?;
template_blocks.push(format!(
"\t{}: {{\n\t\tsubject: {},\n\t\tbody: {},\n\t}},",
escape_identifier_or_key(template_key),
escape_ts_single_quoted(subject),
escape_ts_single_quoted(body)
));
}
if !template_blocks.is_empty() {
replacements.push(append_to_object_body(
target_content,
&target_catalog.object_body,
template_blocks,
));
}
Ok(())
}
fn split_email_context(context: &str) -> Result<(&str, &str)> {
let Some((template, field)) = context.rsplit_once('.') else {
bail!("email catalog context must be <template>.<subject|body>: {context}");
};
if field != "subject" && field != "body" {
bail!("email catalog field must be subject or body: {context}");
}
Ok((template, field))
}
fn append_to_object_body(
content: &str,
body: &Range<usize>,
lines: Vec<String>,
) -> (Range<usize>, String) {
let body_content = &content[body.clone()];
let trimmed_len = body_content.trim_end().len();
let insert_at = body.start + trimmed_len;
let has_existing_entries = !body_content[..trimmed_len].trim().is_empty();
let mut insertion = String::new();
if has_existing_entries && !body_content[..trimmed_len].trim_end().ends_with(',') {
insertion.push(',');
}
insertion.push('\n');
insertion.push_str(&lines.join("\n"));
(insert_at..insert_at, insertion)
}
fn apply_replacements(
content: &str,
mut replacements: Vec<(Range<usize>, String)>,
) -> Result<String> {
replacements.sort_by(|(left, _), (right, _)| {
right
.start
.cmp(&left.start)
.then_with(|| right.end.cmp(&left.end))
});
let mut rebuilt = content.to_string();
let mut previous_start = content.len() + 1;
for (range, replacement) in replacements {
if range.end > previous_start {
bail!("overlapping static TS catalog replacements");
}
rebuilt.replace_range(range.clone(), &replacement);
previous_start = range.start;
}
Ok(rebuilt)
}
fn find_const_object(content: &str, export_name: &str) -> Result<ObjectRange> {
let export_index = content
.find(export_name)
.with_context(|| format!("failed to find static catalog export {export_name}"))?;
let open_relative = content[export_index..]
.find('{')
.with_context(|| format!("failed to find object literal for {export_name}"))?;
object_range_at(content, export_index + open_relative)
}
fn find_call_object(content: &str, function_name: &str) -> Result<ObjectRange> {
let mut search_start = 0;
while let Some(relative_index) = content[search_start..].find(function_name) {
let function_index = search_start + relative_index;
let mut index = function_index + function_name.len();
index = skip_ws_comments(content, index, content.len())?;
if byte_at(content, index) != Some(b'(') {
search_start = index;
continue;
}
index = skip_ws_comments(content, index + 1, content.len())?;
if byte_at(content, index) != Some(b'{') {
search_start = index;
continue;
}
return object_range_at(content, index);
}
bail!("failed to find static locale function call {function_name}");
}
fn object_range_at(content: &str, open_index: usize) -> Result<ObjectRange> {
if byte_at(content, open_index) != Some(b'{') {
bail!("expected object literal at byte {open_index}");
}
let close_index = find_matching_brace(content, open_index)?;
Ok(ObjectRange {
body: open_index + 1..close_index,
})
}
fn find_matching_brace(content: &str, open_index: usize) -> Result<usize> {
let bytes = content.as_bytes();
let mut depth = 0usize;
let mut index = open_index;
let mut state = ScanState::Normal;
while index < bytes.len() {
match state {
ScanState::Normal => match bytes[index] {
b'\'' | b'"' | b'`' => {
state = ScanState::String {
quote: bytes[index],
escaped: false,
};
index += 1;
}
b'/' if byte_at(content, index + 1) == Some(b'/') => {
state = ScanState::LineComment;
index += 2;
}
b'/' if byte_at(content, index + 1) == Some(b'*') => {
state = ScanState::BlockComment;
index += 2;
}
b'{' => {
depth += 1;
index += 1;
}
b'}' => {
depth = depth.saturating_sub(1);
if depth == 0 {
return Ok(index);
}
index += 1;
}
_ => index += 1,
},
ScanState::String { quote, escaped } => {
if escaped {
state = ScanState::String {
quote,
escaped: false,
};
} else if bytes[index] == b'\\' {
state = ScanState::String {
quote,
escaped: true,
};
} else if bytes[index] == quote {
state = ScanState::Normal;
}
index += 1;
}
ScanState::LineComment => {
if bytes[index] == b'\n' {
state = ScanState::Normal;
}
index += 1;
}
ScanState::BlockComment => {
if bytes[index] == b'*' && byte_at(content, index + 1) == Some(b'/') {
state = ScanState::Normal;
index += 2;
} else {
index += 1;
}
}
}
}
bail!("unterminated object literal")
}
#[derive(Clone, Copy)]
enum ScanState {
Normal,
String { quote: u8, escaped: bool },
LineComment,
BlockComment,
}
fn parse_string_properties(
content: &str,
body: &Range<usize>,
) -> Result<Vec<ParsedStringProperty>> {
let mut properties = Vec::new();
let mut index = body.start;
while index < body.end {
index = skip_ws_comments(content, index, body.end)?;
if index >= body.end {
break;
}
if byte_at(content, index) == Some(b',') {
index += 1;
continue;
}
let (key, next_index) = parse_property_key(content, index, body.end)?;
index = skip_ws_comments(content, next_index, body.end)?;
if byte_at(content, index) != Some(b':') {
bail!("expected ':' after property key {key}");
}
index = skip_ws_comments(content, index + 1, body.end)?;
let value = parse_ts_string(content, index, body.end)
.with_context(|| format!("expected string literal value for property {key}"))?;
index = value.value_range.end;
properties.push(ParsedStringProperty {
key,
value: value.value,
value_range: value.value_range,
line_number: line_number_at(content, index),
});
}
Ok(properties)
}
fn parse_object_properties(
content: &str,
body: &Range<usize>,
) -> Result<Vec<ParsedObjectProperty>> {
let mut properties = Vec::new();
let mut index = body.start;
while index < body.end {
index = skip_ws_comments(content, index, body.end)?;
if index >= body.end {
break;
}
if byte_at(content, index) == Some(b',') {
index += 1;
continue;
}
let (key, next_index) = parse_property_key(content, index, body.end)?;
index = skip_ws_comments(content, next_index, body.end)?;
if byte_at(content, index) != Some(b':') {
bail!("expected ':' after object property key {key}");
}
index = skip_ws_comments(content, index + 1, body.end)?;
let object = object_range_at(content, index)
.with_context(|| format!("expected object literal value for property {key}"))?;
index = object.body.end + 1;
properties.push(ParsedObjectProperty {
key,
body: object.body,
});
}
Ok(properties)
}
fn parse_property_key(content: &str, index: usize, end: usize) -> Result<(String, usize)> {
match byte_at(content, index) {
Some(b'\'') | Some(b'"') => {
let parsed = parse_ts_string(content, index, end)?;
Ok((parsed.value, parsed.value_range.end))
}
Some(byte) if is_identifier_start(byte) => {
let mut next = index + 1;
while next < end && byte_at(content, next).is_some_and(is_identifier_part) {
next += 1;
}
Ok((content[index..next].to_string(), next))
}
_ => bail!("expected property key at byte {index}"),
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct ParsedTsString {
value: String,
value_range: Range<usize>,
}
fn parse_ts_string(content: &str, start: usize, end: usize) -> Result<ParsedTsString> {
let quote = byte_at(content, start).context("expected TS string literal")?;
if quote != b'\'' && quote != b'"' {
bail!("expected TS string literal at byte {start}");
}
let mut value = String::new();
let mut index = start + 1;
while index < end {
let character = content[index..]
.chars()
.next()
.context("invalid TS string literal")?;
if character as u32 == quote as u32 {
return Ok(ParsedTsString {
value,
value_range: start..index + 1,
});
}
if character == '\\' {
index += 1;
let escaped = content[index..]
.chars()
.next()
.context("unterminated escape sequence in TS string literal")?;
match escaped {
'\'' => value.push('\''),
'"' => value.push('"'),
'\\' => value.push('\\'),
'n' => value.push('\n'),
'r' => value.push('\r'),
't' => value.push('\t'),
'b' => value.push('\u{0008}'),
'f' => value.push('\u{000c}'),
'v' => value.push('\u{000b}'),
'0' => value.push('\0'),
'x' => {
let (parsed, next_index) = parse_hex_escape(content, index + 1, 2)?;
value.push(parsed);
index = next_index;
continue;
}
'u' => {
let (parsed, next_index) = parse_unicode_escape(content, index + 1)?;
value.push(parsed);
index = next_index;
continue;
}
'\n' => {}
'\r' => {}
other => value.push(other),
}
index += escaped.len_utf8();
} else {
value.push(character);
index += character.len_utf8();
}
}
bail!("unterminated TS string literal at byte {start}")
}
fn parse_hex_escape(content: &str, start: usize, len: usize) -> Result<(char, usize)> {
let end = start + len;
let value = u32::from_str_radix(
content
.get(start..end)
.with_context(|| format!("invalid hex escape at byte {start}"))?,
16,
)
.with_context(|| format!("invalid hex escape at byte {start}"))?;
let character =
char::from_u32(value).with_context(|| format!("invalid code point at byte {start}"))?;
Ok((character, end))
}
fn parse_unicode_escape(content: &str, start: usize) -> Result<(char, usize)> {
if byte_at(content, start) == Some(b'{') {
let close_relative = content[start + 1..]
.find('}')
.with_context(|| format!("unterminated unicode escape at byte {start}"))?;
let digits_start = start + 1;
let digits_end = digits_start + close_relative;
let value = u32::from_str_radix(&content[digits_start..digits_end], 16)
.with_context(|| format!("invalid unicode escape at byte {start}"))?;
let character =
char::from_u32(value).with_context(|| format!("invalid code point at byte {start}"))?;
Ok((character, digits_end + 1))
} else {
parse_hex_escape(content, start, 4)
}
}
fn skip_ws_comments(content: &str, mut index: usize, end: usize) -> Result<usize> {
while index < end {
match byte_at(content, index) {
Some(byte) if byte.is_ascii_whitespace() => index += 1,
Some(b'/') if byte_at(content, index + 1) == Some(b'/') => {
index += 2;
while index < end && byte_at(content, index) != Some(b'\n') {
index += 1;
}
}
Some(b'/') if byte_at(content, index + 1) == Some(b'*') => {
let close_relative = content[index + 2..end]
.find("*/")
.with_context(|| format!("unterminated block comment at byte {index}"))?;
index = index + 2 + close_relative + 2;
}
_ => break,
}
}
Ok(index)
}
fn byte_at(content: &str, index: usize) -> Option<u8> {
content.as_bytes().get(index).copied()
}
fn line_number_at(content: &str, index: usize) -> usize {
content[..index]
.bytes()
.filter(|byte| *byte == b'\n')
.count()
+ 1
}
fn is_identifier_start(byte: u8) -> bool {
byte.is_ascii_alphabetic() || byte == b'_' || byte == b'$'
}
fn is_identifier_part(byte: u8) -> bool {
is_identifier_start(byte) || byte.is_ascii_digit()
}
fn is_identifier(value: &str) -> bool {
let mut bytes = value.bytes();
let Some(first) = bytes.next() else {
return false;
};
is_identifier_start(first) && bytes.all(is_identifier_part)
}
fn escape_property_key(value: &str) -> String {
escape_ts_single_quoted(value)
}
fn escape_identifier_or_key(value: &str) -> String {
if is_identifier(value) {
value.to_string()
} else {
escape_ts_single_quoted(value)
}
}
fn escape_ts_single_quoted(value: &str) -> String {
let mut escaped = String::from("'");
for character in value.chars() {
match character {
'\\' => escaped.push_str("\\\\"),
'\'' => escaped.push_str("\\'"),
'\n' => escaped.push_str("\\n"),
'\r' => escaped.push_str("\\r"),
'\t' => escaped.push_str("\\t"),
'\u{2028}' => escaped.push_str("\\u2028"),
'\u{2029}' => escaped.push_str("\\u2029"),
other => escaped.push(other),
}
}
escaped.push('\'');
escaped
}
#[cfg(test)]
mod tests {
use super::*;
fn simple_config() -> StaticTsCatalogConfig {
StaticTsCatalogConfig {
kind: StaticTsCatalogKind::SimpleMessages,
source_path: PathBuf::from("SourceMessages.ts"),
source_export: "SOURCE_MESSAGES".to_string(),
locale_function: "defineLocaleMessages".to_string(),
}
}
fn email_config() -> StaticTsCatalogConfig {
StaticTsCatalogConfig {
kind: StaticTsCatalogKind::EmailTemplates,
source_path: PathBuf::from("EmailMessages.ts"),
source_export: "EMAIL_MESSAGES".to_string(),
locale_function: "defineEmailMessages".to_string(),
}
}
#[test]
fn reads_and_updates_simple_static_catalogs() {
let source = "export const SOURCE_MESSAGES = {\n\t'hello.world': 'Hello world',\n\t'bye': 'Bye',\n} as const;\n";
let target = "import {defineLocaleMessages} from '../Messages';\n\nexport const DE = defineLocaleMessages({\n\t'hello.world': 'Hallo Welt',\n});\n";
let entries = read_static_ts_entries(&simple_config(), source, target, false).unwrap();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].msgctxt.as_deref(), Some("hello.world"));
assert_eq!(entries[0].msgstr, "Hallo Welt");
assert_eq!(entries[1].msgstr, "");
let rebuilt = rebuild_static_ts_allow_replacing(
&simple_config(),
source,
target,
&[
Translation::new(Some("hello.world".to_string()), "Hello world", "Hallo"),
Translation::new(Some("bye".to_string()), "Bye", "Tschüss"),
],
)
.unwrap();
assert!(rebuilt.contains("'hello.world': 'Hallo',"));
assert!(rebuilt.contains("'bye': 'Tschüss',"));
}
#[test]
fn reads_and_updates_email_template_catalogs() {
let source = "export const EMAIL_MESSAGES = {\n\twelcome: {\n\t\tsubject: 'Welcome to {product_name}',\n\t\tbody: 'Hello {username}\\nWelcome.',\n\t},\n\treset_password: {\n\t\tsubject: 'Reset password',\n\t\tbody: 'Use {resetUrl}',\n\t},\n} as const;\n";
let target = "import {defineEmailMessages} from '../EmailMessages';\n\nexport const DE = defineEmailMessages({\n\twelcome: {\n\t\tsubject: 'Willkommen bei {product_name}',\n\t\tbody: 'Hallo {username}\\nWillkommen.',\n\t},\n});\n";
let entries = read_static_ts_entries(&email_config(), source, target, false).unwrap();
assert_eq!(entries.len(), 4);
assert_eq!(entries[0].msgctxt.as_deref(), Some("welcome.subject"));
assert_eq!(entries[1].msgid, "Hello {username}\nWelcome.");
assert_eq!(entries[2].msgstr, "");
let rebuilt = rebuild_static_ts_allow_replacing(
&email_config(),
source,
target,
&[
Translation::new(
Some("welcome.body".to_string()),
"Hello {username}\nWelcome.",
"Hallo {username}\nGuten Tag.",
),
Translation::new(
Some("reset_password.subject".to_string()),
"Reset password",
"Passwort zurücksetzen",
),
],
)
.unwrap();
assert!(rebuilt.contains("body: 'Hallo {username}\\nGuten Tag.',"));
assert!(rebuilt.contains("reset_password: {"));
assert!(rebuilt.contains("subject: 'Passwort zurücksetzen',"));
assert!(rebuilt.contains("body: 'Use {resetUrl}',"));
}
#[test]
fn reset_clears_existing_static_catalog_values() {
let target =
"export const DE = defineLocaleMessages({\n\t'hello.world': 'Hallo Welt',\n});\n";
let reset = reset_static_ts_translations(&simple_config(), target).unwrap();
assert!(reset.contains("'hello.world': '',"));
}
}