use std::collections::HashSet;
use crate::codegen::serde_enum_repr::tagged_object_tag_key;
use crate::core::ir::{EnumDef, EnumVariant};
use crate::e2e::field_access::PhpGetterMap;
#[derive(Debug, Default)]
pub(super) struct PhpEnumLowering {
flat_class: HashSet<String>,
string_valued: HashSet<String>,
json_bridged: HashSet<String>,
}
impl PhpEnumLowering {
pub(super) fn from_enums(enums: &[EnumDef]) -> Self {
let mut lowering = Self::default();
for enum_def in enums {
if is_tagged_data_enum(enum_def) {
lowering.flat_class.insert(enum_def.name.clone());
} else if is_untagged_data_enum(enum_def) {
lowering.json_bridged.insert(enum_def.name.clone());
} else {
lowering.string_valued.insert(enum_def.name.clone());
}
}
lowering
}
pub(super) fn php_prop_scalar_enum_names(&self) -> ahash::AHashSet<String> {
self.string_valued.iter().cloned().collect()
}
pub(super) fn flat_class_properties(&self, enum_def: &EnumDef) -> Option<Vec<FlatProperty>> {
if !self.flat_class.contains(&enum_def.name) {
return None;
}
let mut seen: HashSet<String> = HashSet::new();
let mut properties = Vec::new();
for variant in &enum_def.variants {
for (index, field) in variant.fields.iter().enumerate() {
let name = flat_field_name(variant, index);
if seen.insert(name.clone()) {
properties.push(FlatProperty {
payload_type: super::types::inner_named(&field.ty),
name,
});
}
}
}
properties.push(FlatProperty {
name: format!("{}_tag", tagged_object_tag_key(enum_def)),
payload_type: None,
});
Some(properties)
}
fn is_memberless(&self, name: &str) -> bool {
self.string_valued.contains(name) || self.json_bridged.contains(name)
}
fn is_flat_class(&self, name: &str) -> bool {
self.flat_class.contains(name)
}
}
fn renderer_can_spell(flat_property: &str) -> bool {
flat_property == heck::ToLowerCamelCase::to_lower_camel_case(flat_property)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum VariantAccess {
Available,
NoAccessor,
UnspellableFlatProperty,
}
pub(super) struct FlatProperty {
pub(super) name: String,
pub(super) payload_type: Option<String>,
}
pub(super) struct PhpVariantAccess<'a> {
getter_map: &'a PhpGetterMap,
lowering: &'a PhpEnumLowering,
}
impl PhpVariantAccess<'static> {
#[cfg(test)]
pub(super) fn none() -> Self {
static GETTER_MAP: std::sync::OnceLock<PhpGetterMap> = std::sync::OnceLock::new();
static LOWERING: std::sync::OnceLock<PhpEnumLowering> = std::sync::OnceLock::new();
Self {
getter_map: GETTER_MAP.get_or_init(PhpGetterMap::default),
lowering: LOWERING.get_or_init(PhpEnumLowering::default),
}
}
}
impl<'a> PhpVariantAccess<'a> {
pub(super) fn new(getter_map: &'a PhpGetterMap, lowering: &'a PhpEnumLowering) -> Self {
Self { getter_map, lowering }
}
pub(super) fn classify(&self, field: &str) -> VariantAccess {
let Some(root) = self.getter_map.root_type.as_deref() else {
return VariantAccess::Available;
};
let mut owner = root.to_string();
let mut remaining = field.split('.').peekable();
while let Some(segment) = remaining.next() {
let Some(next_segment) = remaining.peek().copied() else {
return VariantAccess::Available;
};
let name = segment.split('[').next().unwrap_or(segment);
if !self
.getter_map
.all_fields
.get(&owner)
.is_some_and(|fields| fields.contains(name))
{
return VariantAccess::Available;
}
let Some(next) = self.getter_map.advance(Some(&owner), name) else {
return VariantAccess::Available;
};
if self.lowering.is_memberless(&next) {
return VariantAccess::NoAccessor;
}
let property = next_segment.split('[').next().unwrap_or(next_segment);
if self.lowering.is_flat_class(&next) && !renderer_can_spell(property) {
return VariantAccess::UnspellableFlatProperty;
}
owner = next;
}
VariantAccess::Available
}
pub(super) fn is_unavailable(&self, field: &str) -> bool {
self.classify(field) != VariantAccess::Available
}
}
fn is_tagged_data_enum(enum_def: &EnumDef) -> bool {
enum_def.serde_tag.is_some() && enum_def.variants.iter().any(|variant| !variant.fields.is_empty())
}
fn is_untagged_data_enum(enum_def: &EnumDef) -> bool {
enum_def.serde_untagged && enum_def.variants.iter().any(|variant| !variant.fields.is_empty())
}
fn flat_field_name(variant: &EnumVariant, field_index: usize) -> String {
if crate::codegen::conversions::is_tuple_variant(&variant.fields) {
let base = crate::codegen::naming::pascal_to_snake(&variant.name);
if variant.fields.len() == 1 {
base
} else {
format!("{base}_{field_index}")
}
} else {
variant.fields[field_index].name.clone()
}
}
#[cfg(test)]
mod tests {
use std::collections::{BTreeMap, HashMap, HashSet};
use super::PhpEnumLowering;
use crate::core::config::e2e::CallConfig;
use crate::core::ir::{EnumDef, EnumVariant, FieldDef, PrimitiveType, TypeDef, TypeRef};
use crate::e2e::codegen::field_skip::{FieldSkip, SkipClass};
use crate::e2e::field_access::FieldResolver;
use crate::e2e::fixture::Assertion;
fn field(name: &str, ty: TypeRef) -> FieldDef {
FieldDef {
name: name.to_string(),
ty,
..FieldDef::default()
}
}
fn named(name: &str) -> TypeRef {
TypeRef::Named(name.to_string())
}
fn ir() -> (Vec<TypeDef>, Vec<EnumDef>) {
let type_defs = vec![
TypeDef {
name: "DocumentResult".to_string(),
fields: vec![field("metadata", named("DocumentMetadata"))],
..TypeDef::default()
},
TypeDef {
name: "DocumentMetadata".to_string(),
fields: vec![
field("encoding", named("EncodingDetails")),
field("kind", named("DocumentKind")),
field("payload", named("Payload")),
],
..TypeDef::default()
},
TypeDef {
name: "SpreadsheetDetails".to_string(),
fields: vec![field("sheet_count", TypeRef::Primitive(PrimitiveType::U32))],
..TypeDef::default()
},
];
let enums = vec![
EnumDef {
name: "EncodingDetails".to_string(),
serde_tag: Some("type".to_string()),
variants: vec![
EnumVariant {
name: "Spreadsheet".to_string(),
is_tuple: true,
fields: vec![field("_0", named("SpreadsheetDetails"))],
..EnumVariant::default()
},
EnumVariant {
name: "FictionBook".to_string(),
is_tuple: true,
fields: vec![field("_0", named("SpreadsheetDetails"))],
..EnumVariant::default()
},
],
..EnumDef::default()
},
EnumDef {
name: "DocumentKind".to_string(),
variants: vec![EnumVariant {
name: "Report".to_string(),
..EnumVariant::default()
}],
..EnumDef::default()
},
EnumDef {
name: "Payload".to_string(),
serde_untagged: true,
variants: vec![EnumVariant {
name: "Text".to_string(),
is_tuple: true,
fields: vec![field("_0", TypeRef::String)],
..EnumVariant::default()
}],
..EnumDef::default()
},
];
(type_defs, enums)
}
fn render(field_path: &str) -> String {
let (type_defs, enums) = ir();
let result_fields: HashSet<String> = ["metadata".to_string()].into_iter().collect();
let lowering = PhpEnumLowering::from_enums(&enums);
let getter_map =
super::super::types::build_php_getter_map(&type_defs, &enums, &CallConfig::default(), &result_fields);
let (reachable, excluded, optional) = FieldResolver::ir_field_sets(&type_defs);
let resolver = FieldResolver::new_with_php_getters(
&HashMap::new(),
&HashSet::new(),
&result_fields,
&HashSet::new(),
&HashSet::new(),
&HashMap::new(),
getter_map.clone(),
)
.with_ir_fields(reachable, excluded, optional);
let assertion = Assertion {
assertion_type: "equals".to_string(),
field: Some(field_path.to_string()),
value: Some(serde_json::json!(3)),
..Assertion::default()
};
let mut out = String::new();
super::super::assertions::render_assertion(
&mut out,
&assertion,
"result",
&resolver,
false,
false,
&BTreeMap::new(),
false,
&super::PhpVariantAccess::new(&getter_map, &lowering),
);
out
}
#[test]
fn a_variant_path_through_a_string_lowered_enum_is_skipped_whatever_the_field_is_named() {
let out = render("metadata.kind.report");
assert_eq!(
FieldSkip::extract_classified(&out).map(|(field, skip)| (field, skip.class())),
Some(("metadata.kind.report", SkipClass::LanguageLimitation)),
"got: {out}"
);
}
#[test]
fn a_variant_path_through_a_json_lowered_enum_is_skipped() {
let out = render("metadata.payload.text");
assert_eq!(
FieldSkip::extract_classified(&out).map(|(field, skip)| (field, skip.class())),
Some(("metadata.payload.text", SkipClass::LanguageLimitation)),
"got: {out}"
);
}
#[test]
fn a_variant_path_through_a_flat_class_enum_asserts_instead_of_skipping() {
let out = render("metadata.encoding.spreadsheet.sheet_count");
assert_eq!(FieldSkip::extract_classified(&out), None, "got: {out}");
assert!(
out.contains("$result->getMetadata()->getEncoding()->spreadsheet->sheetCount"),
"got: {out}"
);
}
#[test]
fn a_multi_word_flat_property_is_refused_as_a_generator_gap_not_emitted_camel_cased() {
let out = render("metadata.encoding.fiction_book.sheet_count");
assert_eq!(
FieldSkip::extract_classified(&out).map(|(field, skip)| (field, skip.class())),
Some(("metadata.encoding.fiction_book.sheet_count", SkipClass::GeneratorGap)),
"got: {out}"
);
assert!(!out.contains("fictionBook"), "got: {out}");
}
#[test]
fn a_path_ending_on_the_enum_itself_is_not_skipped() {
let out = render("metadata.kind");
assert_eq!(FieldSkip::extract_classified(&out), None, "got: {out}");
assert!(out.contains("$result->getMetadata()->kind"), "got: {out}");
}
#[test]
fn should_match_the_binding_backends_partition() {
let (_, enums) = ir();
let lowering = PhpEnumLowering::from_enums(&enums);
let scalars = lowering.php_prop_scalar_enum_names();
assert!(
scalars.contains("DocumentKind"),
"unit-variant enums map to a PHP string"
);
assert!(
!scalars.contains("EncodingDetails"),
"a tagged data enum is a flat class, not a #[php(prop)] scalar"
);
assert!(
!scalars.contains("Payload"),
"an untagged data enum is bridged as JSON, not a #[php(prop)] scalar"
);
assert!(lowering.flat_class_properties(&enums[1]).is_none());
let properties = lowering.flat_class_properties(&enums[0]).expect("flat class");
let names: Vec<&str> = properties.iter().map(|p| p.name.as_str()).collect();
assert_eq!(names, vec!["spreadsheet", "fiction_book", "type_tag"]);
}
}