#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PlaceholderToken {
pub index: Option<usize>,
pub kind: char,
}
impl PlaceholderToken {
pub fn to_signature(&self) -> String {
match self.index {
Some(i) => format!("{}${}", i, self.kind),
None => format!("{}", self.kind),
}
}
}
pub fn extract_placeholders(input: &str) -> Vec<PlaceholderToken> {
const FLAGS: &[u8] = b"-+ #0',(<";
const CONVERSIONS: &[u8] = b"bBhHdiuoxXfFeEgGaAcCsSpn@";
const DATE_TIME_SUFFIXES: &[u8] = b"HIklMSLNpzZsQBbhAaCYyjmdeRTrDFc";
let bytes = input.as_bytes();
let mut i = 0;
let mut out = Vec::new();
let mut previous_value_index: Option<Option<usize>> = None;
while i < bytes.len() {
if bytes[i] != b'%' {
i += 1;
continue;
}
if i + 1 < bytes.len() && bytes[i + 1] == b'%' {
i += 2;
continue;
}
if bytes.get(i + 1) == Some(&b'n') {
i += 2;
continue;
}
if is_percent_before_word(input, i) {
i += 1;
continue;
}
let mut cursor = i + 1;
let value_index = parse_positional_index(bytes, &mut cursor);
let mut dynamic_arguments = Vec::new();
let mut reuse_previous = false;
while cursor < bytes.len() && FLAGS.contains(&bytes[cursor]) {
reuse_previous |= bytes[cursor] == b'<';
cursor += 1;
}
if bytes.get(cursor) == Some(&b'*') {
cursor += 1;
let width_index = parse_positional_index(bytes, &mut cursor);
dynamic_arguments.push(PlaceholderToken {
index: width_index,
kind: '*',
});
} else {
while bytes.get(cursor).is_some_and(|byte| byte.is_ascii_digit()) {
cursor += 1;
}
}
if bytes.get(cursor) == Some(&b'.') {
cursor += 1;
if bytes.get(cursor) == Some(&b'*') {
cursor += 1;
let precision_index = parse_positional_index(bytes, &mut cursor);
dynamic_arguments.push(PlaceholderToken {
index: precision_index,
kind: '*',
});
} else {
while bytes.get(cursor).is_some_and(|byte| byte.is_ascii_digit()) {
cursor += 1;
}
}
}
let java_simple_conversion = match bytes.get(cursor).copied() {
Some(b'b' | b'B') => Some('b'),
Some(b'H') => Some('h'),
Some(b'h')
if !(bytes
.get(cursor + 1)
.is_some_and(|conversion| b"diuoxXn".contains(conversion))
|| bytes.get(cursor + 1) == Some(&b'h')
&& bytes
.get(cursor + 2)
.is_some_and(|conversion| b"diuoxXn".contains(conversion))) =>
{
Some('h')
}
_ => None,
};
let conversion = if let Some(kind) = java_simple_conversion {
Some(kind)
} else if bytes
.get(cursor)
.is_some_and(|byte| matches!(byte, b't' | b'T'))
&& bytes
.get(cursor + 1)
.is_some_and(|suffix| DATE_TIME_SUFFIXES.contains(suffix))
&& !(bytes.get(cursor) == Some(&b't')
&& bytes
.get(cursor + 1)
.is_some_and(|suffix| b"diuoxXn".contains(suffix)))
{
cursor += 1;
Some('t')
} else {
if bytes.get(cursor..).is_some_and(|remaining| {
remaining.starts_with(b"hh") || remaining.starts_with(b"ll")
}) {
cursor += 2;
} else if bytes
.get(cursor)
.is_some_and(|byte| b"hljztL".contains(byte))
{
cursor += 1;
}
bytes
.get(cursor)
.copied()
.filter(|conversion| CONVERSIONS.contains(conversion))
.map(|conversion| canonical_kind_char(conversion as char))
};
if let Some(kind) = conversion {
let effective_index = if reuse_previous {
if value_index.is_some() {
i += 1;
continue;
}
match previous_value_index {
Some(Some(index)) => Some(index),
Some(None) => Some(0),
None => {
i += 1;
continue;
}
}
} else {
value_index
};
out.extend(dynamic_arguments);
out.push(PlaceholderToken {
index: effective_index,
kind,
});
i = cursor + 1;
if !reuse_previous {
previous_value_index = Some(value_index);
}
continue;
}
i += 1;
}
out
}
fn is_percent_before_word(input: &str, percent: usize) -> bool {
let rest = &input[percent + 1..];
let mut chars = rest.chars();
chars.next() == Some(' ')
&& chars.next().is_some_and(|c| c.is_ascii_alphabetic())
&& chars.next().is_some_and(char::is_alphabetic)
}
fn parse_positional_index(bytes: &[u8], cursor: &mut usize) -> Option<usize> {
let start = *cursor;
while bytes.get(*cursor).is_some_and(|byte| byte.is_ascii_digit()) {
*cursor += 1;
}
if *cursor == start || bytes.get(*cursor) != Some(&b'$') {
*cursor = start;
return None;
}
let index = std::str::from_utf8(&bytes[start..*cursor])
.ok()
.and_then(|value| value.parse().ok());
*cursor += 1;
index
}
pub fn normalize_placeholders(input: &str) -> String {
let bytes = input.as_bytes();
let mut i = 0;
let mut tmp = String::with_capacity(input.len());
while i < bytes.len() {
if bytes[i] == b'%' {
let mut j = i + 1;
let start_digits = j;
while j < bytes.len() && bytes[j].is_ascii_digit() {
j += 1;
}
if j > start_digits && j + 1 < bytes.len() && bytes[j] == b'$' && bytes[j + 1] == b'@' {
tmp.push('%');
tmp.push_str(&input[start_digits..j]); tmp.push('$');
tmp.push('s');
i = j + 2;
continue;
}
}
let ch = input[i..]
.chars()
.next()
.expect("valid UTF-8 slicing while scanning placeholders");
tmp.push(ch);
i += ch.len_utf8();
}
let out = tmp.replace("%@", "%s");
let out = out.replace("%ld", "%d");
out.replace("%lu", "%u")
}
pub fn to_ios_placeholders(input: &str) -> String {
let bytes = input.as_bytes();
let mut i = 0usize;
let mut out = String::with_capacity(input.len());
while i < bytes.len() {
if bytes[i] != b'%' {
let ch = input[i..]
.chars()
.next()
.expect("valid UTF-8 slicing while converting placeholders");
out.push(ch);
i += ch.len_utf8();
continue;
}
if i + 1 < bytes.len() && bytes[i + 1] == b'%' {
out.push('%');
out.push('%');
i += 2;
continue;
}
let mut j = i + 1;
let start_digits = j;
while j < bytes.len() && bytes[j].is_ascii_digit() {
j += 1;
}
let mut had_positional = false;
if j > start_digits && j < bytes.len() && bytes[j] == b'$' {
had_positional = true;
j += 1; } else {
j = i + 1;
}
let mut k = j;
while k < bytes.len() && bytes[k] == b'l' {
k += 1;
}
if k >= bytes.len() {
out.push('%');
i += 1;
continue;
}
let ty = bytes[k] as char;
if ty == 's' {
out.push('%');
if had_positional {
out.push_str(
&input[start_digits..(if start_digits < j {
j - 1
} else {
start_digits
})],
);
out.push('$');
}
out.push('@');
i = k + 1;
continue;
}
out.push('%');
i += 1;
}
out
}
pub fn signature(input: &str) -> Vec<String> {
let mut tokens = extract_placeholders(input);
let has_explicit = tokens
.iter()
.any(|token| token.index.is_some_and(|index| index > 0));
let has_implicit = tokens.iter().any(|token| token.index.is_none());
if !has_explicit {
canonicalize_implicit_indices(&mut tokens, true);
tokens.sort_by_key(|token| (token.index, token.kind));
} else if !has_implicit && tokens.iter().all(|token| token.index != Some(0)) {
tokens.sort_by_key(|token| (token.index, token.kind));
} else {
canonicalize_implicit_indices(&mut tokens, false);
}
tokens.into_iter().map(|t| t.to_signature()).collect()
}
fn canonicalize_implicit_indices(tokens: &mut [PlaceholderToken], assign_all: bool) {
let mut next_index = 1;
let mut previous_implicit_index = None;
for token in tokens {
match token.index {
None => {
let index = next_index;
next_index += 1;
previous_implicit_index = Some(index);
if assign_all {
token.index = Some(index);
}
}
Some(0) => {
token.index = previous_implicit_index;
}
Some(_) => {}
}
}
}
fn canonical_kind_char(ch: char) -> char {
match ch {
'@' => 's',
c => c.to_ascii_lowercase(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_android_and_ios() {
let s = "Hello %1$@, you have %2$d items and %s extra";
let sig = signature(s);
assert_eq!(sig, vec!["1$s", "2$d", "s"]);
let s = "你好,%s";
let sig = signature(s);
assert_eq!(sig, vec!["1$s"]);
}
#[test]
fn test_normalize_ios_simple() {
let s = "Value: %@ and number %ld";
let n = normalize_placeholders(s);
assert!(n.contains("%s"));
assert!(n.contains("%d"));
assert_eq!(signature(s), vec!["1$s", "2$d"]);
}
#[test]
fn test_normalize_positional_object() {
let s = "Hello %1$@";
let n = normalize_placeholders(s);
assert!(n.contains("%1$s"));
}
#[test]
fn test_ignore_escaped_percent() {
let s = "Discount: 50%% and value %d";
let sig = signature(s);
assert_eq!(sig, vec!["1$d"]);
}
#[test]
fn extracts_flags_width_precision_and_length_modifiers() {
assert_eq!(signature("%02d %.2f %zu"), vec!["1$d", "2$f", "3$u"]);
assert_eq!(signature("%1$.2f"), vec!["1$f"]);
assert_eq!(
signature("%hhd %hd %ld %lld %jd %tu %Lf"),
vec!["1$d", "2$d", "3$d", "4$d", "5$d", "6$u", "7$f"]
);
}
#[test]
fn dynamic_width_and_precision_are_argument_tokens() {
assert_eq!(signature("%*.*f"), vec!["1$*", "2$*", "3$f"]);
assert_eq!(signature("%3$*1$.*2$f"), vec!["1$*", "2$*", "3$f"]);
}
#[test]
fn positional_arguments_compare_by_identity_not_occurrence() {
assert_eq!(signature("%2$d %1$@"), signature("%1$s %2$d"));
assert_ne!(signature("%d %s"), signature("%s %d"));
}
#[test]
fn wholly_implicit_arguments_equal_explicit_identity_sequence() {
assert_eq!(signature("%@ %d"), signature("%1$s %2$d"));
assert_eq!(signature("%@ %d"), vec!["1$s", "2$d"]);
}
#[test]
fn mixed_addressing_remains_order_sensitive_and_distinct() {
assert_ne!(signature("%1$s %d"), signature("%d %1$s"));
assert_ne!(signature("%1$s %d"), signature("%1$s %2$d"));
}
#[test]
fn extracts_android_java_boolean_hash_and_date_time_conversions() {
assert_eq!(signature("%b %B %h %H"), vec!["1$b", "2$b", "3$h", "4$h"]);
assert_eq!(signature("%2$Tm %1$tY"), vec!["1$t", "2$t"]);
assert_eq!(signature("%tF %Tc"), vec!["1$t", "2$t"]);
}
#[test]
fn unified_dialect_prefers_c_length_and_java_bare_newline() {
assert_eq!(signature("%td %tY"), vec!["1$d", "2$t"]);
assert_eq!(signature("first%nsecond %d"), vec!["1$d"]);
assert_eq!(signature("%1$n"), vec!["1$n"]);
}
#[test]
fn java_flags_do_not_hide_conversions_or_become_static_width() {
assert_eq!(signature("%,12d %(08d"), vec!["1$d", "2$d"]);
assert_eq!(signature("%#b %+d"), vec!["1$b", "2$d"]);
}
#[test]
fn percent_sign_followed_by_a_word_is_prose_not_a_space_flag_conversion() {
assert_eq!(signature("Get 5% bonus"), Vec::<String>::new());
assert_eq!(
signature("5% de descuento, 50% off, 100% free, 5% new"),
Vec::<String>::new()
);
assert_eq!(signature("Gana 5% syiling y %d monedas"), vec!["1$d"]);
assert_eq!(signature("5% 金币 %@"), vec!["1$s"]);
}
#[test]
fn space_flag_conversions_that_end_a_word_remain_arguments() {
assert_eq!(signature("% d"), vec!["1$d"]);
assert_eq!(signature("[% d] % 5d"), vec!["1$d", "2$d"]);
assert_eq!(signature("% d%%"), vec!["1$d"]);
assert_eq!(signature("%+dx"), vec!["1$d"]);
}
#[test]
fn java_previous_argument_reuse_is_not_a_new_unindexed_argument() {
assert_eq!(signature("%s %<S"), vec!["1$s", "1$s"]);
assert_eq!(signature("%s %<S"), signature("%1$s %1$s"));
assert_ne!(signature("%s %<s"), signature("%s %s"));
assert_eq!(signature("%2$s %<S"), vec!["2$s", "2$s"]);
}
}