use std::collections::{HashMap, HashSet};
use crate::generated::descriptor::{DescriptorProto, FileDescriptorProto};
use crate::impl_message::is_real_oneof_member;
use crate::CodeGenWarning;
pub(crate) fn idiomatic_snake_case(s: &str) -> String {
let chars: Vec<char> = s.chars().collect();
let mut out = String::with_capacity(s.len() + 4);
let mut last_cased: Option<char> = None;
for (i, &c) in chars.iter().enumerate() {
if c == '_' {
last_cased = None;
out.push('_');
continue;
}
if c.is_uppercase() && i > 0 {
let boundary = match last_cased {
Some(p) if p.is_lowercase() => true,
Some(p) if p.is_uppercase() => {
chars.get(i + 1).is_some_and(|n| n.is_lowercase())
}
_ => false,
};
if boundary {
out.push('_');
}
}
out.extend(c.to_lowercase());
if c.is_lowercase() || c.is_uppercase() {
last_cased = Some(c);
}
}
out
}
#[derive(Debug, Default)]
pub(crate) struct FieldNamePlan {
pub field_renames: HashMap<(String, i32), String>,
pub oneof_keep_verbatim: HashSet<String>,
pub warnings: Vec<CodeGenWarning>,
}
fn rank(name: &str, number: i32, value: &str) -> u8 {
if value == name {
2
} else {
debug_assert!(value.ends_with(&format!("_f{number}")));
1
}
}
pub(crate) fn plan_field_names(files: &[FileDescriptorProto]) -> FieldNamePlan {
let mut plan = FieldNamePlan::default();
let mut assigned: HashSet<(String, String)> = HashSet::new();
for_each_message(files, |fqn, msg| {
plan_message(fqn, msg, &mut plan, &mut assigned)
});
for_each_message(files, |fqn, msg| {
sweep_inherited(fqn, msg, &mut plan, &assigned)
});
plan
}
fn for_each_message(files: &[FileDescriptorProto], mut f: impl FnMut(&str, &DescriptorProto)) {
fn recurse(fqn: &str, msg: &DescriptorProto, f: &mut impl FnMut(&str, &DescriptorProto)) {
if msg
.options
.as_option()
.and_then(|o| o.map_entry)
.unwrap_or(false)
{
return;
}
for nested in &msg.nested_type {
let nested_fqn = format!("{fqn}.{}", nested.name.as_deref().unwrap_or_default());
recurse(&nested_fqn, nested, f);
}
f(fqn, msg);
}
for file in files {
let package = file.package.as_deref().unwrap_or("");
for msg in &file.message_type {
let fqn = if package.is_empty() {
msg.name.clone().unwrap_or_default()
} else {
format!("{package}.{}", msg.name.as_deref().unwrap_or_default())
};
recurse(&fqn, msg, &mut f);
}
}
}
fn sweep_inherited(
fqn: &str,
msg: &DescriptorProto,
plan: &mut FieldNamePlan,
assigned: &HashSet<(String, String)>,
) {
let mut inherited: Vec<(String, String)> = Vec::new();
for field in &msg.field {
let Some(name) = field.name.as_deref() else {
continue;
};
if is_real_oneof_member(field) {
continue;
}
if assigned.contains(&(fqn.to_string(), name.to_string())) {
continue;
}
let key = (name.to_string(), field.number.unwrap_or(0));
if let Some(renamed) = plan.field_renames.get(&key) {
inherited.push((name.to_string(), renamed.clone()));
}
}
for oneof in &msg.oneof_decl {
let Some(name) = oneof.name.as_deref() else {
continue;
};
if assigned.contains(&(fqn.to_string(), name.to_string()))
|| !plan.oneof_keep_verbatim.contains(name)
|| idiomatic_snake_case(name) == name
{
continue;
}
inherited.push((name.to_string(), name.to_string()));
}
if !inherited.is_empty() {
inherited.sort();
plan.warnings
.push(CodeGenWarning::IdiomaticFieldNamesAdjusted {
message_name: fqn.to_string(),
assignments: inherited,
});
}
}
struct Member<'a> {
proto_name: &'a str,
number: Option<i32>,
converted: String,
}
fn plan_message(
fqn: &str,
msg: &DescriptorProto,
plan: &mut FieldNamePlan,
assigned: &mut HashSet<(String, String)>,
) {
let mut members: Vec<Member<'_>> = Vec::new();
let mut real_oneofs: HashSet<i32> = HashSet::new();
for field in &msg.field {
let Some(name) = field.name.as_deref() else {
continue;
};
if is_real_oneof_member(field) {
if let Some(idx) = field.oneof_index {
real_oneofs.insert(idx);
}
continue;
}
members.push(Member {
proto_name: name,
number: Some(field.number.unwrap_or(0)),
converted: idiomatic_snake_case(name),
});
}
for (idx, oneof) in msg.oneof_decl.iter().enumerate() {
let Some(name) = oneof.name.as_deref() else {
continue;
};
if !real_oneofs.contains(&(i32::try_from(idx).unwrap_or(i32::MAX))) {
continue; }
members.push(Member {
proto_name: name,
number: None,
converted: idiomatic_snake_case(name),
});
}
let mut groups: HashMap<&str, Vec<usize>> = HashMap::new();
for (i, m) in members.iter().enumerate() {
groups.entry(m.converted.as_str()).or_default().push(i);
}
let colliding: Vec<&Vec<usize>> = groups.values().filter(|g| g.len() > 1).collect();
if colliding.is_empty() {
return;
}
let mut finals: Vec<String> = members.iter().map(|m| m.converted.clone()).collect();
let mut adjusted: Vec<usize> = Vec::new();
for group in &colliding {
for &i in group.iter() {
let m = &members[i];
if m.converted == m.proto_name {
continue; }
finals[i] = match m.number {
Some(number) => format!("{}_f{number}", m.converted),
None => m.proto_name.to_string(),
};
adjusted.push(i);
}
}
let unique: HashSet<&str> = finals.iter().map(String::as_str).collect();
if unique.len() != finals.len() {
for (i, m) in members.iter().enumerate() {
if m.converted != m.proto_name {
finals[i] = m.proto_name.to_string();
if !adjusted.contains(&i) {
adjusted.push(i);
}
}
}
}
let mut assignments: Vec<(String, String)> = Vec::new();
for &i in &adjusted {
let m = &members[i];
assignments.push((m.proto_name.to_string(), finals[i].clone()));
assigned.insert((fqn.to_string(), m.proto_name.to_string()));
match m.number {
Some(number) => {
let key = (m.proto_name.to_string(), number);
let replace = match plan.field_renames.get(&key) {
None => true,
Some(existing) => {
rank(m.proto_name, number, &finals[i])
> rank(m.proto_name, number, existing)
}
};
if replace {
plan.field_renames.insert(key, finals[i].clone());
}
}
None => {
plan.oneof_keep_verbatim.insert(m.proto_name.to_string());
}
}
}
assignments.sort();
plan.warnings
.push(CodeGenWarning::IdiomaticFieldNamesAdjusted {
message_name: fqn.to_string(),
assignments,
});
}
#[cfg(test)]
mod tests {
use super::*;
use crate::generated::descriptor::{
DescriptorProto, FieldDescriptorProto, OneofDescriptorProto,
};
fn field(name: &str, number: i32) -> FieldDescriptorProto {
FieldDescriptorProto {
name: Some(name.to_string()),
number: Some(number),
..Default::default()
}
}
fn msg(name: &str, fields: Vec<FieldDescriptorProto>) -> DescriptorProto {
DescriptorProto {
name: Some(name.to_string()),
field: fields,
..Default::default()
}
}
fn file(messages: Vec<DescriptorProto>) -> FileDescriptorProto {
FileDescriptorProto {
name: Some("test.proto".to_string()),
package: Some("pkg".to_string()),
message_type: messages,
..Default::default()
}
}
#[test]
fn idiomatic_snake_case_matches_heck_where_they_agree() {
for (input, expected) in [
("FooBar", "foo_bar"),
("FooBarBAZ", "foo_bar_baz"),
("XMLHttpRequest", "xml_http_request"),
("While", "while"),
("FUZZ_BUSTER", "fuzz_buster"),
("foo_bar_baz", "foo_bar_baz"),
("FUZZ_buster", "fuzz_buster"),
("fieldname1", "fieldname1"),
("field_name2", "field_name2"),
("field0name5", "field0name5"),
("field_0_name6", "field_0_name6"),
("fieldName7", "field_name7"),
("FieldName8", "field_name8"),
("field_Name9", "field_name9"),
("Field_Name10", "field_name10"),
("FIELD_NAME11", "field_name11"),
("FIELD_name12", "field_name12"),
] {
assert_eq!(idiomatic_snake_case(input), expected, "input {input}");
}
}
#[test]
fn idiomatic_snake_case_digit_transparent_boundaries() {
assert_eq!(idiomatic_snake_case("v2Field"), "v2_field");
assert_eq!(idiomatic_snake_case("field0Name"), "field0_name");
assert_eq!(idiomatic_snake_case("ipV6Address"), "ip_v6_address");
assert_eq!(idiomatic_snake_case("HTTP2Server"), "http2_server");
assert_eq!(idiomatic_snake_case("A2B"), "a2b");
assert_eq!(idiomatic_snake_case("foo_2Bar"), "foo_2bar");
}
#[test]
fn idiomatic_snake_case_preserves_authored_underscores() {
assert_eq!(idiomatic_snake_case("_field_name3"), "_field_name3");
assert_eq!(idiomatic_snake_case("field__name4_"), "field__name4_");
assert_eq!(idiomatic_snake_case("_fuzz"), "_fuzz");
assert_eq!(idiomatic_snake_case("fuzz_"), "fuzz_");
assert_eq!(idiomatic_snake_case("_fieldName3"), "_field_name3");
assert_eq!(idiomatic_snake_case("Fuzz_"), "fuzz_");
}
#[test]
fn no_collisions_produces_empty_plan() {
let f = file(vec![msg("M", vec![field("fooBar", 1), field("baz", 2)])]);
let plan = plan_field_names(&[f]);
assert!(plan.field_renames.is_empty());
assert!(plan.oneof_keep_verbatim.is_empty());
assert!(plan.warnings.is_empty());
}
#[test]
fn changed_field_is_suffixed_unchanged_keeps_name() {
let f = file(vec![msg(
"M",
vec![field("user_name", 11), field("userName", 12)],
)]);
let plan = plan_field_names(&[f]);
assert!(!plan
.field_renames
.contains_key(&("user_name".to_string(), 11)));
assert_eq!(
plan.field_renames.get(&("userName".to_string(), 12)),
Some(&"user_name_f12".to_string())
);
assert_eq!(plan.warnings.len(), 1);
}
#[test]
fn two_changed_fields_both_suffixed() {
let f = file(vec![msg("M", vec![field("fooBar", 1), field("FooBar", 2)])]);
let plan = plan_field_names(&[f]);
assert_eq!(
plan.field_renames.get(&("fooBar".to_string(), 1)),
Some(&"foo_bar_f1".to_string())
);
assert_eq!(
plan.field_renames.get(&("FooBar".to_string(), 2)),
Some(&"foo_bar_f2".to_string())
);
}
#[test]
fn colliding_oneof_keeps_verbatim() {
let mut m = msg("M", vec![field("data_value", 1)]);
m.oneof_decl.push(OneofDescriptorProto {
name: Some("dataValue".to_string()),
..Default::default()
});
let mut member = field("inner", 2);
member.oneof_index = Some(0);
m.field.push(member);
let plan = plan_field_names(&[file(vec![m])]);
assert!(plan.oneof_keep_verbatim.contains("dataValue"));
assert!(plan.field_renames.is_empty());
}
#[test]
fn suffix_collision_reverts_group_to_verbatim() {
let f = file(vec![msg(
"M",
vec![
field("user_name", 11),
field("userName", 12),
field("user_name_f12", 13),
],
)]);
let plan = plan_field_names(&[f]);
assert_eq!(
plan.field_renames.get(&("userName".to_string(), 12)),
Some(&"userName".to_string())
);
}
#[test]
fn inherited_adjustment_warns_for_contaminated_message() {
let f = file(vec![
msg("A", vec![field("user_name", 11), field("userName", 12)]),
msg("B", vec![field("userName", 12)]),
]);
let plan = plan_field_names(&[f]);
let warned: Vec<&str> = plan
.warnings
.iter()
.map(|w| match w {
CodeGenWarning::IdiomaticFieldNamesAdjusted { message_name, .. } => {
message_name.as_str()
}
_ => unreachable!(),
})
.collect();
assert!(warned.contains(&"pkg.A"), "{warned:?}");
assert!(warned.contains(&"pkg.B"), "{warned:?}");
let f2 = file(vec![
msg("A", vec![field("user_name", 11), field("userName", 12)]),
msg("C", vec![field("userName", 7)]),
]);
let plan2 = plan_field_names(&[f2]);
assert_eq!(plan2.warnings.len(), 1);
}
#[test]
fn nested_messages_are_planned() {
let mut outer = msg("Outer", vec![]);
outer
.nested_type
.push(msg("Inner", vec![field("a_b", 1), field("aB", 2)]));
let plan = plan_field_names(&[file(vec![outer])]);
assert_eq!(
plan.field_renames.get(&("aB".to_string(), 2)),
Some(&"a_b_f2".to_string())
);
assert!(matches!(
&plan.warnings[0],
CodeGenWarning::IdiomaticFieldNamesAdjusted { message_name, .. }
if message_name == "pkg.Outer.Inner"
));
}
#[test]
fn synthetic_oneof_is_not_a_namespace_member() {
let mut m = msg("M", vec![]);
let mut opt = field("fooBar", 1);
opt.oneof_index = Some(0);
opt.proto3_optional = Some(true);
m.field.push(opt);
m.field.push(field("foo_bar", 2));
m.oneof_decl.push(OneofDescriptorProto {
name: Some("_fooBar".to_string()),
..Default::default()
});
let plan = plan_field_names(&[file(vec![m])]);
assert_eq!(
plan.field_renames.get(&("fooBar".to_string(), 1)),
Some(&"foo_bar_f1".to_string())
);
assert!(plan.oneof_keep_verbatim.is_empty());
}
}