use std::collections::HashMap;
use std::collections::HashSet;
use openapiv3::ReferenceOr;
use crate::config::DEFAULT_RESPONSE_SUFFIX;
use crate::config::OUTPUT_OPTIONS_KEY;
use crate::config::RESPONSE_TYPE_SUFFIX_KEY;
use crate::config::TYPE_NAME_SUFFIX_KEY;
use crate::emit::ReservedTypeName;
use crate::emit::Targets;
use crate::error::Error;
use crate::error::Result;
use crate::ir::EnumKind;
use crate::ir::Item;
use crate::ir::Module;
use crate::ir::RequestPayload;
use crate::ir::ResponseBody;
use crate::ir::RustType;
use crate::ir::Service;
use crate::loader::Spec;
use crate::naming::Case;
use crate::naming::RustIdent;
use crate::naming::X_RUST_NAME;
use crate::naming::to_ident;
#[derive(Debug)]
pub struct TypeNames {
renames: HashMap<String, String>,
collisions: Vec<Collision>,
}
#[derive(Debug)]
struct Collision {
ident: String,
first: String,
second: String,
overridden: bool,
}
impl TypeNames {
pub fn renames(&self) -> &HashMap<String, String> {
return &self.renames;
}
pub fn check_emitted(&self, module: &Module) -> Result<()> {
let emitted: HashSet<&str> = module.items.iter().map(|item| return item.name()).collect();
let mut diagnostics = crate::lower::validate::Diagnostics::new();
for collision in &self.collisions {
if !emitted.contains(collision.ident.as_str()) {
continue;
}
diagnostics.push(Error::SchemaNameCollision {
ident: collision.ident.clone(),
first: collision.first.clone(),
second: collision.second.clone(),
hint: collision_hint(&collision.ident, &collision.second, collision.overridden),
});
}
return diagnostics.into_result();
}
}
pub fn type_renames(spec: &Spec, suffix: Option<&str>) -> Result<TypeNames> {
let suffix = checked_suffix(suffix)?;
let mut resolved = HashMap::new();
let mut collisions = Vec::new();
let mut claimed: HashMap<String, String> = HashMap::new();
for (name, entry) in spec.schemas() {
let override_name = match entry {
ReferenceOr::Item(schema) => {
crate::lower::extension::str_value(&schema.schema_data.extensions, X_RUST_NAME, name)?
}
ReferenceOr::Reference { .. } => None,
};
let effective = override_name.unwrap_or(name);
let mut ident = to_ident(effective, Case::Pascal);
if let Some(first) = claimed.get(ident.logical()) {
match suffix {
Some(suffix) => {
ident = suffixed_ident(&ident, suffix, &claimed);
}
None => {
collisions.push(Collision {
ident: ident.logical().to_owned(),
first: first.clone(),
second: name.clone(),
overridden: override_name.is_some(),
});
continue;
}
}
}
claimed.insert(ident.logical().to_owned(), name.clone());
if ident.logical() != to_ident(name, Case::Pascal).logical() {
resolved.insert(name.clone(), ident.logical().to_owned());
}
}
return Ok(TypeNames {
renames: resolved,
collisions,
});
}
fn checked_suffix(suffix: Option<&str>) -> Result<Option<&str>> {
let Some(suffix) = suffix else {
return Ok(None);
};
const STEM: &str = "Placeholder";
if to_ident(&format!("{STEM} {suffix}"), Case::Pascal).logical() != STEM {
return Ok(Some(suffix));
}
return Err(Error::InvalidTypeNameSuffix {
suffix: suffix.to_owned(),
hint: format!(
"Casing removes punctuation and separators, so `{suffix}` leaves the type name unchanged. \
Use a suffix with at least one letter or digit (for example \
`{TYPE_NAME_SUFFIX_KEY}: Alt`). To make a collision an error instead, remove \
`{OUTPUT_OPTIONS_KEY}.{TYPE_NAME_SUFFIX_KEY}`.",
),
});
}
fn suffixed_ident(ident: &RustIdent, suffix: &str, claimed: &HashMap<String, String>) -> RustIdent {
let mut candidate = to_ident(&format!("{} {suffix}", ident.logical()), Case::Pascal);
while claimed.contains_key(candidate.logical()) {
let longer = to_ident(&format!("{} {suffix}", candidate.logical()), Case::Pascal);
if longer.logical() == candidate.logical() {
return candidate;
}
candidate = longer;
}
return candidate;
}
fn collision_hint(ident: &str, second: &str, overridden: bool) -> String {
if overridden {
return format!(
"`{second}` already sets `{X_RUST_NAME}`, and that name also resolves to `{ident}`. \
Give `{second}` a name that no other schema uses.",
);
}
return format!(
"Give one of the two schemas a different Rust name with `{X_RUST_NAME}`, which records the \
type name the author wants. To rename every later collision instead, set \
`{OUTPUT_OPTIONS_KEY}.{TYPE_NAME_SUFFIX_KEY}` (for example `{TYPE_NAME_SUFFIX_KEY}: Alt`, \
which emits `{ident}` and `{ident}Alt`).",
);
}
pub fn rewrite_module(module: &mut Module, renames: &HashMap<String, String>) {
if renames.is_empty() {
return;
}
for item in &mut module.items {
rewrite_item(item, renames);
}
}
pub fn rewrite_service(service: &mut Service, renames: &HashMap<String, String>) {
if renames.is_empty() {
return;
}
visit_service_types(service, &mut |ty| {
if let RustType::Named(name) = ty
&& let Some(custom) = renames.get(name.as_str())
{
*name = custom.clone();
}
});
}
fn visit_service_types(service: &mut Service, visit: &mut dyn FnMut(&mut RustType)) {
for operation in &mut service.operations {
for param in &mut operation.path_params {
visit_type(&mut param.ty, visit);
}
if let Some(query) = &mut operation.query {
for field in &mut query.fields {
visit_type(&mut field.ty, visit);
}
if let Some(additional) = &mut query.additional_properties {
visit_type(additional, visit);
}
}
if let Some(headers) = &mut operation.headers {
for param in &mut headers.params {
visit_type(&mut param.ty, visit);
}
}
if let Some(cookies) = &mut operation.cookies {
for param in &mut cookies.params {
visit_type(&mut param.ty, visit);
}
}
if let Some(request) = &mut operation.request {
match request {
RequestPayload::Single(body) => visit_type(&mut body.ty, visit),
RequestPayload::Multipart(multipart) => {
for field in &mut multipart.fields {
visit_type(&mut field.ty, visit);
}
}
RequestPayload::Negotiated(negotiated) => {
for variant in &mut negotiated.variants {
visit_type(&mut variant.body.ty, visit);
}
}
}
}
for response in &mut operation.responses {
match &mut response.body {
Some(ResponseBody::Single(body)) => visit_type(&mut body.ty, visit),
Some(ResponseBody::Negotiated(negotiated)) => {
for variant in &mut negotiated.variants {
visit_type(&mut variant.body.ty, visit);
}
}
None => {}
}
for header in &mut response.headers {
visit_type(&mut header.ty, visit);
}
}
}
}
fn visit_type(ty: &mut RustType, visit: &mut dyn FnMut(&mut RustType)) {
match ty {
RustType::Vec(inner) | RustType::Map(inner) | RustType::Option(inner) | RustType::Boxed(inner) => {
visit_type(inner, visit);
}
leaf => visit(leaf),
}
}
fn rewrite_item(item: &mut Item, renames: &HashMap<String, String>) {
match item {
Item::Struct(strukt) => {
for field in &mut strukt.fields {
rewrite_type(&mut field.ty, renames);
}
if let Some(additional) = &mut strukt.additional_properties {
rewrite_type(additional, renames);
}
}
Item::Enum(enumeration) => {
if let EnumKind::Union(variants) = &mut enumeration.kind {
for variant in variants {
rewrite_type(&mut variant.ty, renames);
}
}
}
Item::Alias(alias) => rewrite_type(&mut alias.ty, renames),
}
}
fn rewrite_type(ty: &mut RustType, renames: &HashMap<String, String>) {
match ty {
RustType::Named(name) => {
if let Some(custom) = renames.get(name.as_str()) {
*name = custom.clone();
}
}
RustType::Vec(inner) | RustType::Map(inner) | RustType::Option(inner) | RustType::Boxed(inner) => {
rewrite_type(inner, renames);
}
_ => {}
}
}
pub fn check_duplicate_models(module: &Module) -> Result<()> {
let mut diagnostics = crate::lower::validate::Diagnostics::new();
let mut seen: HashSet<&str> = HashSet::new();
for item in &module.items {
if !seen.insert(item.name()) {
diagnostics.push(Error::DuplicateTypeName {
name: item.name().to_owned(),
hint: duplicate_model_hint(item.name()),
});
}
}
return diagnostics.into_result();
}
pub fn check_prelude_shadowing(module: &Module, targets: Targets) -> Result<()> {
let mut diagnostics = crate::lower::validate::Diagnostics::new();
let prelude = crate::emit::prelude_type_names(targets);
for item in &module.items {
let Some(shadowed) = prelude.iter().find(|entry| return entry.name == item.name()) else {
continue;
};
diagnostics.push(Error::PreludeShadowing {
name: shadowed.name.to_owned(),
used_for: shadowed.used_for.to_owned(),
hint: format!("Rename the schema with `{X_RUST_NAME}`, or with `output-options.type-name-suffix`."),
});
}
return diagnostics.into_result();
}
enum Claim {
Model,
Reserved(&'static str),
Artifact {
kind: &'static str,
operation: String,
},
}
pub fn check_type_name_collisions(service: &Service, module: &Module, reserved: &[ReservedTypeName]) -> Result<()> {
let mut diagnostics = crate::lower::validate::Diagnostics::new();
let mut claimed: HashMap<String, Claim> = module
.items
.iter()
.map(|item| return (item.name().to_owned(), Claim::Model))
.collect();
for name in reserved {
match claimed.insert(name.name.to_owned(), Claim::Reserved(name.description)) {
Some(Claim::Model) => {
claimed.insert(name.name.to_owned(), Claim::Model);
diagnostics.push(Error::TypeNameCollision {
name: name.name.to_owned(),
artifact: name.description.to_owned(),
hint: format!("rename the schema with `{X_RUST_NAME}`"),
});
}
Some(Claim::Reserved(_) | Claim::Artifact { .. }) | None => {}
}
}
for operation in &service.operations {
let mut claim = |name: &RustIdent, kind: &'static str| {
claim_artifact(&mut claimed, &mut diagnostics, name, kind, &operation.name);
};
claim(&operation.response_enum, "response enum");
if let Some(query) = &operation.query {
claim(&query.name, "query-parameter struct");
}
if let Some(headers) = &operation.headers {
claim(&headers.name, "header-parameter struct");
}
if let Some(cookies) = &operation.cookies {
claim(&cookies.name, "cookie-parameter struct");
}
match &operation.request {
Some(RequestPayload::Multipart(multipart)) => claim(&multipart.name, "multipart request struct"),
Some(RequestPayload::Negotiated(request)) => claim(&request.name, "request-body enum"),
Some(RequestPayload::Single(_)) | None => {}
}
for response in &operation.responses {
if let Some(ResponseBody::Negotiated(body)) = &response.body {
claim(&body.name, "response-body enum");
}
}
}
return diagnostics.into_result();
}
fn claim_artifact(
claimed: &mut HashMap<String, Claim>,
diagnostics: &mut crate::lower::validate::Diagnostics,
name: &RustIdent,
kind: &'static str,
operation: &RustIdent,
) {
let ident = name.logical();
match claimed.get(ident) {
None => {
claimed.insert(
ident.to_owned(),
Claim::Artifact {
kind,
operation: operation.logical().to_owned(),
},
);
}
Some(Claim::Model) => {
diagnostics.push(Error::TypeNameCollision {
name: ident.to_owned(),
artifact: kind.to_owned(),
hint: model_clash_hint(kind),
});
}
Some(Claim::Reserved(description)) => {
diagnostics.push(Error::OperationTypeCollision {
name: ident.to_owned(),
first: format!("the {description}"),
second: format!("the {kind} of operation `{}`", operation.logical()),
hint: reserved_clash_hint(operation.logical()),
});
}
Some(Claim::Artifact {
kind: first_kind,
operation: first_operation,
}) => {
diagnostics.push(Error::OperationTypeCollision {
name: ident.to_owned(),
first: format!("the {first_kind} of operation `{first_operation}`"),
second: format!("the {kind} of operation `{}`", operation.logical()),
hint: artifact_clash_hint(first_operation, operation.logical()),
});
}
}
}
fn duplicate_model_hint(name: &str) -> String {
return format!(
"One of these comes from an inline schema that the generator hoists to the crate root, and \
an inline schema carries no name to override. Give the enclosing component schema a \
different Rust name with `{X_RUST_NAME}`, or move the inline schema into its own component \
schema, name that component something other than `{name}`, and refer to it with `$ref`.",
);
}
fn model_clash_hint(kind: &str) -> String {
if kind != "response enum" {
return format!("rename the schema with `{X_RUST_NAME}`");
}
let default_suffix = to_ident(DEFAULT_RESPONSE_SUFFIX, Case::Pascal);
return format!(
"give the colliding schema a different Rust name with `{X_RUST_NAME}` — a surgical, \
per-schema fix that leaves the other response enums untouched — or, to rename every \
response enum, set `{OUTPUT_OPTIONS_KEY}.{RESPONSE_TYPE_SUFFIX_KEY}` to a suffix other \
than the default `{}` (for example `{RESPONSE_TYPE_SUFFIX_KEY}: Resp`, which renames \
the enum to `<Op>Resp`)",
default_suffix.logical(),
);
}
fn reserved_clash_hint(operation: &str) -> String {
return format!(
"The generator emits this name for a requested target, so the name cannot move. Give \
operation `{operation}` a different method name with `{X_RUST_NAME}`, or change \
`{OUTPUT_OPTIONS_KEY}.{RESPONSE_TYPE_SUFFIX_KEY}` if that suffix produced the name.",
);
}
fn artifact_clash_hint(first_operation: &str, second_operation: &str) -> String {
if first_operation == second_operation {
return format!(
"Both names belong to operation `{first_operation}`, so no method name can separate \
them. Set `{OUTPUT_OPTIONS_KEY}.{RESPONSE_TYPE_SUFFIX_KEY}` to a suffix that no \
parameter-struct or body-enum name already ends with.",
);
}
return format!(
"Every per-operation type derives from the method name of its operation. Give operation \
`{first_operation}` or operation `{second_operation}` a different method name with \
`{X_RUST_NAME}`.",
);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn suffix_with_identifier_characters_is_accepted() {
for suffix in ["Alt", "2", "a", "-v2", "_alt"] {
let outcome = checked_suffix(Some(suffix));
assert!(
matches!(outcome, Ok(Some(kept)) if kept == suffix),
"`{suffix}` adds characters to a type name and must be accepted",
);
}
}
#[test]
fn absent_suffix_stays_absent() {
assert!(matches!(checked_suffix(None), Ok(None)));
}
#[test]
fn suffix_without_identifier_characters_is_rejected() {
for suffix in ["", " ", "-", "_", "...", "-_-"] {
let outcome = checked_suffix(Some(suffix));
assert!(
matches!(outcome, Err(Error::InvalidTypeNameSuffix { .. })),
"`{suffix}` adds nothing to a type name and must be rejected",
);
}
}
#[test]
fn rejected_suffix_names_both_remedies() {
let Err(err) = checked_suffix(Some("-")) else {
panic!("`-` must be rejected");
};
let Error::InvalidTypeNameSuffix { hint, .. } = &err else {
panic!("expected an InvalidTypeNameSuffix, got {err:?}");
};
assert!(hint.contains(TYPE_NAME_SUFFIX_KEY), "hint names the key: {hint}");
assert!(hint.contains("Alt"), "hint gives a working example: {hint}");
assert!(hint.contains("remove"), "hint offers the error mode: {hint}");
assert!(!err.to_string().contains(hint.as_str()));
}
#[test]
fn suffixed_ident_grows_until_the_name_is_free() {
let mut claimed: HashMap<String, String> = HashMap::new();
claimed.insert("OrderItem".to_owned(), "order-item".to_owned());
claimed.insert("OrderItemAlt".to_owned(), "orderItem".to_owned());
let ident = suffixed_ident(&to_ident("order-item", Case::Pascal), "Alt", &claimed);
assert_eq!(ident.logical(), "OrderItemAltAlt");
}
}