use serde_json::Value;
use super::attrs;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Rename {
Lower,
Upper,
Pascal,
Camel,
Snake,
ScreamingSnake,
}
impl Rename {
pub fn parse(s: &str) -> Option<Self> {
Some(match s {
"lowercase" => Self::Lower,
"UPPERCASE" => Self::Upper,
"PascalCase" => Self::Pascal,
"camelCase" => Self::Camel,
"snake_case" => Self::Snake,
"SCREAMING_SNAKE_CASE" => Self::ScreamingSnake,
_ => return None,
})
}
pub fn apply(self, name: &str) -> String {
match self {
Self::Lower => name.to_lowercase(),
Self::Upper => name.to_uppercase(),
Self::Pascal => camel_like(name, true),
Self::Camel => camel_like(name, false),
Self::Snake => snake_like(name, false),
Self::ScreamingSnake => snake_like(name, true),
}
}
}
fn camel_like(name: &str, lead_upper: bool) -> String {
let body = name.trim_end_matches(|c: char| !c.is_alphanumeric());
let mut out = String::with_capacity(body.len());
let (mut new_word, mut last, mut found) = (lead_upper, ' ', false);
for c in body.chars() {
if !c.is_alphanumeric() {
if found {
new_word = true;
}
} else if c.is_numeric() {
(found, new_word) = (true, true);
out.push(c);
} else if new_word || (last.is_lowercase() && c.is_uppercase()) {
(found, new_word) = (true, false);
out.push(c.to_ascii_uppercase());
} else {
(found, last) = (true, c);
out.push(c.to_ascii_lowercase());
}
}
out
}
fn snake_like(name: &str, upper: bool) -> String {
let chars: Vec<char> = name.chars().collect();
let end = chars
.iter()
.rposition(|c| c.is_alphanumeric())
.map_or(0, |i| i + 1);
let cased = |c: char| match upper {
true => c.to_ascii_uppercase(),
false => c.to_ascii_lowercase(),
};
let mut out = String::with_capacity(name.len() * 2);
let mut first = true;
for (i, &c) in chars[..end].iter().enumerate() {
if !c.is_alphanumeric() {
if !first {
first = true;
out.push('_');
}
continue;
}
if !first && c == c.to_ascii_uppercase() && neighbour_is_lower(&chars, i) {
out.push('_');
}
first = false;
out.push(cased(c));
}
out
}
fn neighbour_is_lower(chars: &[char], i: usize) -> bool {
chars.get(i + 1).is_some_and(|c| c.is_lowercase()) || (i > 0 && chars[i - 1].is_lowercase())
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct ContainerAttrs {
pub rename_fields: Option<Rename>,
pub rename_items: Option<Rename>,
pub unknown_rename_rule: Option<String>,
pub present: bool,
}
impl ContainerAttrs {
pub fn field_rule(&self) -> Rename {
self.rename_fields.unwrap_or(Rename::Camel)
}
pub fn item_rule(&self) -> Rename {
self.rename_items.unwrap_or(Rename::ScreamingSnake)
}
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct FieldAttrs {
pub name: Option<String>,
pub skip: bool,
}
pub fn container(raw: &[Value]) -> ContainerAttrs {
let mut out = ContainerAttrs::default();
for args in attrs::args(raw, "graphql") {
out.present = true;
for item in attrs::split_items(&args) {
let (key, value) = attrs::key_value(item);
let slot = match key {
"rename_fields" => &mut out.rename_fields,
"rename_items" => &mut out.rename_items,
_ => continue,
};
let Some(v) = value else { continue };
match Rename::parse(&v) {
Some(r) => *slot = Some(r),
None => out.unknown_rename_rule = Some(v),
}
}
}
out
}
pub fn field(raw: &[Value]) -> FieldAttrs {
let mut out = FieldAttrs::default();
for args in attrs::args(raw, "graphql") {
for item in attrs::split_items(&args) {
match attrs::key_value(item) {
("name", Some(v)) => out.name = Some(v),
("skip", None) => out.skip = true,
_ => {}
}
}
}
out
}
pub fn variant(raw: &[Value]) -> FieldAttrs {
field(raw)
}
pub fn wire_name_field(rust_name: &str, f: &FieldAttrs, c: &ContainerAttrs) -> String {
match &f.name {
Some(n) => n.clone(),
None => c.field_rule().apply(rust_name),
}
}
pub fn wire_name_variant(rust_name: &str, v: &FieldAttrs, c: &ContainerAttrs) -> String {
match &v.name {
Some(n) => n.clone(),
None => c.item_rule().apply(rust_name),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn raw(items: &[&str]) -> Vec<Value> {
items.iter().map(|s| json!({ "other": s })).collect()
}
#[test]
fn camel_case_opens_a_word_at_every_digit() {
assert_eq!(
Rename::Camel.apply("price_change_percentage_24h"),
"priceChangePercentage24H"
);
assert_eq!(
Rename::Camel.apply("market_cap_change_percentage_24h_usd"),
"marketCapChangePercentage24HUsd"
);
assert_eq!(Rename::Camel.apply("x2y"), "x2Y");
assert_eq!(Rename::Camel.apply("a_1_b"), "a1B");
}
#[test]
fn camel_case_handles_the_degenerate_identifiers() {
assert_eq!(Rename::Camel.apply("k"), "k");
assert_eq!(Rename::Camel.apply("report_date"), "reportDate");
assert_eq!(Rename::Camel.apply("_leading"), "leading");
assert_eq!(Rename::Camel.apply("double__under"), "doubleUnder");
assert_eq!(Rename::Camel.apply("trailing_"), "trailing");
assert_eq!(Rename::Camel.apply("alreadyCamel"), "alreadyCamel");
assert_eq!(Rename::Camel.apply("FOO_BAR"), "fooBar");
assert_eq!(Rename::Camel.apply("HTTPServer"), "httpserver");
assert_eq!(Rename::Camel.apply(""), "");
}
#[test]
fn pascal_case_differs_from_camel_only_in_the_first_word() {
assert_eq!(Rename::Pascal.apply("report_date"), "ReportDate");
assert_eq!(
Rename::Pascal.apply("price_change_percentage_24h"),
"PriceChangePercentage24H"
);
assert_eq!(Rename::Pascal.apply("k"), "K");
assert_eq!(Rename::Pascal.apply("_leading"), "Leading");
}
#[test]
fn snake_case_splits_digits_the_way_inflector_does() {
assert_eq!(
Rename::Snake.apply("price_change_percentage_24h"),
"price_change_percentage_2_4h"
);
assert_eq!(Rename::Snake.apply("alreadyCamel"), "already_camel");
assert_eq!(Rename::Snake.apply("HTTPServer"), "http_server");
assert_eq!(Rename::Snake.apply("v2_api"), "v_2_api");
assert_eq!(Rename::Snake.apply("trailing_"), "trailing");
assert_eq!(
Rename::ScreamingSnake.apply("price_change_percentage_24h"),
"PRICE_CHANGE_PERCENTAGE_2_4H"
);
assert_eq!(Rename::ScreamingSnake.apply("NotFound"), "NOT_FOUND");
assert_eq!(Rename::ScreamingSnake.apply("OkThen"), "OK_THEN");
}
#[test]
fn lowercase_and_uppercase_leave_separators_alone() {
assert_eq!(Rename::Lower.apply("Report_Date"), "report_date");
assert_eq!(Rename::Upper.apply("report_date"), "REPORT_DATE");
}
#[test]
fn defaults_are_camel_for_fields_and_screaming_snake_for_items() {
let c = ContainerAttrs::default();
assert_eq!(c.field_rule(), Rename::Camel);
assert_eq!(c.item_rule(), Rename::ScreamingSnake);
}
#[test]
fn container_rules_parse_independently() {
let c = container(&raw(&[
"#[graphql(rename_fields = \"snake_case\", rename_items = \"camelCase\")]",
]));
assert_eq!(c.rename_fields, Some(Rename::Snake));
assert_eq!(c.rename_items, Some(Rename::Camel));
assert!(c.present);
assert_eq!(c.unknown_rename_rule, None);
}
#[test]
fn an_unknown_rule_is_reported_and_the_default_kept() {
let c = container(&raw(&["#[graphql(rename_fields = \"kebab-case\")]"]));
assert_eq!(c.rename_fields, None);
assert_eq!(c.unknown_rename_rule.as_deref(), Some("kebab-case"));
assert_eq!(c.field_rule(), Rename::Camel);
}
#[test]
fn serde_attributes_are_not_mistaken_for_graphql_ones() {
let c = container(&raw(&["#[serde(rename_all = \"snake_case\")]"]));
assert_eq!(c, ContainerAttrs::default());
assert!(!c.present);
}
#[test]
fn field_name_beats_the_container_rule() {
let c = container(&raw(&["#[graphql(rename_fields = \"camelCase\")]"]));
let f = field(&raw(&["#[graphql(name = \"customName\")]"]));
assert_eq!(wire_name_field("renamed_field", &f, &c), "customName");
let plain = FieldAttrs::default();
assert_eq!(wire_name_field("renamed_field", &plain, &c), "renamedField");
}
#[test]
fn skip_is_read_off_the_field() {
assert!(field(&raw(&["#[graphql(skip)]"])).skip);
assert!(!field(&raw(&["#[graphql(name = \"a\")]"])).skip);
}
#[test]
fn variant_name_beats_the_item_rule() {
let c = container(&raw(&["#[graphql(rename_items = \"camelCase\")]"]));
assert_eq!(
wire_name_variant("NotFound", &FieldAttrs::default(), &c),
"notFound"
);
let named = variant(&raw(&["#[graphql(name = \"WEIRD\")]"]));
assert_eq!(wire_name_variant("Weird", &named, &c), "WEIRD");
}
}