Skip to content

Commit 81ab956

Browse files
committed
feat(placeholder): add conversion from Android-style to iOS-style placeholders
- Implemented `to_ios_placeholders` function to convert Android-style string placeholders (%s, %1$s) to iOS-style (%@). - Updated `strings.rs` and `xcstrings.rs` to utilize the new conversion function during entry processing. - Added tests to ensure correct conversion of placeholders in both string and xcstrings formats.
1 parent 450557f commit 81ab956

3 files changed

Lines changed: 162 additions & 3 deletions

File tree

langcodec/src/formats/strings.rs

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -504,7 +504,7 @@ impl TryFrom<Entry> for Pair {
504504
match entry.value {
505505
Translation::Singular(value) => Ok(Pair {
506506
key: entry.id,
507-
value,
507+
value: crate::placeholder::to_ios_placeholders(&value),
508508
comment: entry.comment,
509509
}),
510510
Translation::Plural(_) => Err(Error::DataMismatch(
@@ -628,6 +628,28 @@ mod tests {
628628
assert!(out_str.contains(r#""key2" = "Can\\'t accept";"#));
629629
}
630630

631+
#[test]
632+
fn test_strings_writer_ios_placeholder_conversion() {
633+
// Build a Resource with Android-style placeholders and ensure writer converts to iOS style
634+
let resource = Resource {
635+
metadata: Metadata {
636+
language: "en".to_string(),
637+
domain: String::new(),
638+
custom: HashMap::new(),
639+
},
640+
entries: vec![Entry {
641+
id: "g".to_string(),
642+
value: Translation::Singular("Hi %1$s and %s".to_string()),
643+
comment: None,
644+
status: EntryStatus::Translated,
645+
custom: HashMap::new(),
646+
}],
647+
};
648+
let fmt = Format::try_from(resource).unwrap();
649+
assert_eq!(fmt.pairs.len(), 1);
650+
assert_eq!(fmt.pairs[0].value, "Hi %1$@ and %@");
651+
}
652+
631653
#[test]
632654
fn test_multiline_value_with_embedded_newlines_and_whitespace() {
633655
let content = r#"

langcodec/src/formats/xcstrings.rs

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,7 @@ impl Item {
186186
Localization {
187187
string_unit: Some(StringUnit {
188188
state: entry.status,
189-
value,
189+
value: crate::placeholder::to_ios_placeholders(&value),
190190
}),
191191
variations: None,
192192
},
@@ -200,7 +200,7 @@ impl Item {
200200
PluralVariation {
201201
string_unit: Some(StringUnit {
202202
state: entry.status.clone(),
203-
value,
203+
value: crate::placeholder::to_ios_placeholders(&value),
204204
}),
205205
},
206206
);
@@ -344,3 +344,69 @@ pub struct PluralVariation {
344344
#[serde(skip_serializing_if = "Option::is_none")]
345345
pub string_unit: Option<StringUnit>,
346346
}
347+
348+
#[cfg(test)]
349+
mod tests {
350+
use super::*;
351+
352+
#[test]
353+
fn test_ios_placeholder_conversion_in_writer() {
354+
// Build resources that contain Android-style placeholders
355+
let res = Resource {
356+
metadata: Metadata {
357+
language: "en".to_string(),
358+
domain: String::new(),
359+
custom: {
360+
let mut m = HashMap::new();
361+
m.insert("source_language".into(), "en".into());
362+
m.insert("version".into(), "1.0".into());
363+
m
364+
},
365+
},
366+
entries: vec![
367+
Entry {
368+
id: "greet".into(),
369+
value: Translation::Singular("Hello %1$s and %s".into()),
370+
comment: None,
371+
status: EntryStatus::Translated,
372+
custom: HashMap::new(),
373+
},
374+
Entry {
375+
id: "files".into(),
376+
value: Translation::Plural(Plural {
377+
id: "files".into(),
378+
forms: {
379+
let mut f = std::collections::BTreeMap::new();
380+
f.insert(PluralCategory::One, "%1$s file".into());
381+
f.insert(PluralCategory::Other, "%1$s files".into());
382+
f
383+
},
384+
}),
385+
comment: None,
386+
status: EntryStatus::Translated,
387+
custom: HashMap::new(),
388+
},
389+
],
390+
};
391+
392+
let fmt = Format::try_from(vec![res]).expect("xcstrings from resources");
393+
// greet
394+
let item = fmt.strings.get("greet").expect("greet item");
395+
let en = item.localizations.get("en").expect("en loc");
396+
let val = en.string_unit.as_ref().unwrap().value.clone();
397+
assert!(val.contains("%1$@") && val.contains("%@"));
398+
399+
// plurals
400+
let files = fmt.strings.get("files").expect("files item");
401+
let en_p = files.localizations.get("en").expect("en loc");
402+
let plural_map = en_p.variations.as_ref().unwrap().plural.as_ref().unwrap();
403+
assert!(plural_map
404+
.get(&PluralCategory::One)
405+
.unwrap()
406+
.string_unit
407+
.as_ref()
408+
.unwrap()
409+
.value
410+
.contains("%1$@"));
411+
}
412+
}

langcodec/src/placeholder.rs

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,77 @@ pub fn normalize_placeholders(input: &str) -> String {
126126
out.replace("%lu", "%u")
127127
}
128128

129+
/// Convert canonical/Android-style string placeholders to iOS-style.
130+
/// - %s -> %@
131+
/// - %1$s -> %1$@
132+
/// Leaves numeric specifiers (e.g., %d, %u, %ld) unchanged.
133+
pub fn to_ios_placeholders(input: &str) -> String {
134+
let bytes = input.as_bytes();
135+
let mut i = 0usize;
136+
let mut out = String::with_capacity(input.len());
137+
while i < bytes.len() {
138+
if bytes[i] != b'%' {
139+
out.push(bytes[i] as char);
140+
i += 1;
141+
continue;
142+
}
143+
// Escaped percent '%%'
144+
if i + 1 < bytes.len() && bytes[i + 1] == b'%' {
145+
out.push('%');
146+
out.push('%');
147+
i += 2;
148+
continue;
149+
}
150+
151+
// Examine potential placeholder
152+
let mut j = i + 1;
153+
// Optional positional index digits+$
154+
let start_digits = j;
155+
while j < bytes.len() && bytes[j].is_ascii_digit() {
156+
j += 1;
157+
}
158+
let mut had_positional = false;
159+
if j > start_digits && j < bytes.len() && bytes[j] == b'$' {
160+
had_positional = true;
161+
j += 1; // skip '$'
162+
} else {
163+
// reset if not positional
164+
j = i + 1;
165+
}
166+
167+
// Optional length modifiers (l/ll). We will drop them when converting %s -> %@.
168+
let mut k = j;
169+
while k < bytes.len() && bytes[k] == b'l' {
170+
k += 1;
171+
}
172+
if k >= bytes.len() {
173+
// not a complete placeholder, copy '%' and advance
174+
out.push('%');
175+
i += 1;
176+
continue;
177+
}
178+
179+
let ty = bytes[k] as char;
180+
if ty == 's' {
181+
// Emit converted iOS placeholder
182+
out.push('%');
183+
if had_positional {
184+
// copy the digits we saw
185+
out.push_str(&input[start_digits..(if start_digits < j { j - 1 } else { start_digits })]);
186+
out.push('$');
187+
}
188+
out.push('@');
189+
i = k + 1;
190+
continue;
191+
}
192+
193+
// Not a string placeholder, emit one byte and continue (simple path)
194+
out.push('%');
195+
i += 1;
196+
}
197+
out
198+
}
199+
129200
/// Build a normalized signature (sequence of tokens) for comparison.
130201
pub fn signature(input: &str) -> Vec<String> {
131202
extract_placeholders(&normalize_placeholders(input))

0 commit comments

Comments
 (0)