oapi_codegen/lower/rename.rs
1//! Resolution of top-level type names across the lowered IR.
2//!
3//! A generated type name differs from the plain `to_ident(schema_name)` for two
4//! reasons. The schema carries an `x-rust-name` override, or two schema names
5//! collapse onto one Rust identifier and the config supplies a suffix. Every
6//! reference to a schema lowers to a [`RustType::Named`] that holds the original
7//! schema name. Name resolution therefore must also rewrite those references to
8//! point at the final identifier. Lowering sets the item names from the same
9//! resolution map. See [`crate::lower::schema`]. This pass rewrites the `Named`
10//! references that still point at the original name.
11//!
12//! An unresolved collision is not reported here. Default pruning drops a schema
13//! that no operation uses, and a collision between two dropped schemas never
14//! reaches the output. [`type_renames`] therefore returns the collisions it found,
15//! and the caller reports them when the set of emitted models is final.
16
17use std::collections::HashMap;
18use std::collections::HashSet;
19
20use openapiv3::ReferenceOr;
21
22use crate::config::DEFAULT_RESPONSE_SUFFIX;
23use crate::config::OUTPUT_OPTIONS_KEY;
24use crate::config::RESPONSE_TYPE_SUFFIX_KEY;
25use crate::config::TYPE_NAME_SUFFIX_KEY;
26use crate::emit::ReservedTypeName;
27use crate::emit::Targets;
28use crate::error::Error;
29use crate::error::Result;
30use crate::ir::EnumKind;
31use crate::ir::Item;
32use crate::ir::Module;
33use crate::ir::RequestPayload;
34use crate::ir::ResponseBody;
35use crate::ir::RustType;
36use crate::ir::Service;
37use crate::loader::Spec;
38use crate::naming::Case;
39use crate::naming::RustIdent;
40use crate::naming::X_RUST_NAME;
41use crate::naming::to_ident;
42
43/// The final Rust type name of every top-level schema, together with each
44/// collision that still needs a decision from the author.
45///
46/// A collision is not an error at resolution time. Default pruning drops a schema
47/// that no generated operation uses, and two dropped schemas that collapse onto
48/// one Rust identifier cause no problem in the output. The caller therefore holds
49/// this value until the module is final and then calls [`TypeNames::check_emitted`].
50#[derive(Debug)]
51pub struct TypeNames {
52 /// A map from an original schema name to the final Rust type identifier.
53 renames: HashMap<String, String>,
54 /// Every collision found, in document order.
55 collisions: Vec<Collision>,
56}
57
58/// Two schema names that collapse onto one Rust identifier, and no suffix to
59/// tell them apart. The fields are kept instead of a built [`Error`], because the
60/// caller decides later whether this collision reaches the output at all.
61#[derive(Debug)]
62struct Collision {
63 /// The Rust identifier that both schemas produce.
64 ident: String,
65 /// The schema that claimed `ident` first.
66 first: String,
67 /// The schema that collided with `first`.
68 second: String,
69 /// Whether `second` carries an `x-rust-name`, which changes the remedy.
70 overridden: bool,
71}
72
73impl TypeNames {
74 /// The renames to apply to every `Named` reference and item name.
75 ///
76 /// The map holds one entry for each top-level schema whose emitted name
77 /// differs from the plain `to_ident(name)`. Two sources add an entry:
78 ///
79 /// * an `x-rust-name` override (inline schemas only), and
80 /// * a collision suffix from `output-options.type-name-suffix`, added to the
81 /// second of two schema names that collapse onto one Rust identifier.
82 ///
83 /// The map omits a schema whose emitted name does not change. The common case
84 /// therefore gives an empty map, and the rewrite passes do no work.
85 ///
86 /// An unresolved collision adds no entry, so both schemas keep the plain
87 /// name. The module then holds two items with that one name, which is why an
88 /// unchecked collision must never reach the emitter.
89 pub fn renames(&self) -> &HashMap<String, String> {
90 return &self.renames;
91 }
92
93 /// Report a collision whose Rust type name `module` still holds, and collect
94 /// every such collision into one result.
95 ///
96 /// The emitted name is what decides. Two items with one name do not compile,
97 /// whatever schema each item came from, and a schema that `module` does not
98 /// hold emits no item at all. The check is therefore keyed on the identifier
99 /// and not on the provenance of the two schemas.
100 ///
101 /// Keying on the identifier also covers a name that a hoisted inline type
102 /// takes. Pruning is name-based (see [`crate::lower::prune`]), so an inline
103 /// item named `FooBar` keeps two unused `foo-bar` and `fooBar` components
104 /// alive. Three items then share one name. The collision is real there, even
105 /// though no operation reaches either component.
106 ///
107 /// A caller that prunes nothing gets a module that holds every schema, so
108 /// every collision reports.
109 ///
110 /// # Errors
111 ///
112 /// Returns one [`Error::SchemaNameCollision`] for a single surviving
113 /// collision, or an [`Error::Validation`] that holds all of them.
114 pub fn check_emitted(&self, module: &Module) -> Result<()> {
115 let emitted: HashSet<&str> = module.items.iter().map(|item| return item.name()).collect();
116 let mut diagnostics = crate::lower::validate::Diagnostics::new();
117 for collision in &self.collisions {
118 if !emitted.contains(collision.ident.as_str()) {
119 continue;
120 }
121 diagnostics.push(Error::SchemaNameCollision {
122 ident: collision.ident.clone(),
123 first: collision.first.clone(),
124 second: collision.second.clone(),
125 hint: collision_hint(&collision.ident, &collision.second, collision.overridden),
126 });
127 }
128 return diagnostics.into_result();
129 }
130}
131
132/// Resolve the Rust type name of every top-level schema in `spec`.
133///
134/// Two distinct schema names can collapse onto one Rust identifier. For example,
135/// `foo-bar` and `fooBar` both become `FooBar`. When `suffix` is set, the second
136/// name takes the suffix. When `suffix` is `None`, the collision is recorded in
137/// the returned [`TypeNames`] for the caller to report, because the generator
138/// will not choose a name for one of two distinct schemas. That choice belongs to
139/// the author.
140///
141/// # Errors
142///
143/// A `suffix` that contributes no characters to an identifier is an error.
144/// Casing removes punctuation, so a suffix such as `-` leaves the name unchanged
145/// and cannot resolve a collision. A valid suffix holds a letter or a digit.
146pub fn type_renames(spec: &Spec, suffix: Option<&str>) -> Result<TypeNames> {
147 let suffix = checked_suffix(suffix)?;
148 let mut resolved = HashMap::new();
149 let mut collisions = Vec::new();
150 // Maps a claimed identifier back to the schema name that claimed it, so a
151 // collision error can name the earlier schema and not the identifier alone.
152 let mut claimed: HashMap<String, String> = HashMap::new();
153 for (name, entry) in spec.schemas() {
154 let override_name = match entry {
155 ReferenceOr::Item(schema) => {
156 crate::lower::extension::str_value(&schema.schema_data.extensions, X_RUST_NAME, name)?
157 }
158 ReferenceOr::Reference { .. } => None,
159 };
160 let effective = override_name.unwrap_or(name);
161 let mut ident = to_ident(effective, Case::Pascal);
162 if let Some(first) = claimed.get(ident.logical()) {
163 match suffix {
164 Some(suffix) => {
165 ident = suffixed_ident(&ident, suffix, &claimed);
166 }
167 None => {
168 collisions.push(Collision {
169 ident: ident.logical().to_owned(),
170 first: first.clone(),
171 second: name.clone(),
172 overridden: override_name.is_some(),
173 });
174 continue;
175 }
176 }
177 }
178 claimed.insert(ident.logical().to_owned(), name.clone());
179 if ident.logical() != to_ident(name, Case::Pascal).logical() {
180 resolved.insert(name.clone(), ident.logical().to_owned());
181 }
182 }
183 return Ok(TypeNames {
184 renames: resolved,
185 collisions,
186 });
187}
188
189/// Reject a configured suffix that adds nothing to a Rust type name.
190///
191/// Casing drops punctuation and separators. `to_ident("Foo -")` therefore gives
192/// `Foo` again, and the same holds for an empty suffix. Such a suffix cannot
193/// resolve a collision, because the second name stays the same as the first.
194/// [`suffixed_ident`] would search for a free name that it can never produce.
195///
196/// The check runs one time for the whole document, and not for each schema,
197/// because the suffix comes from the config and does not change per schema.
198///
199/// This returns an error and does not treat the suffix as unset. A silent
200/// fallback would report a collision and tell the author to set
201/// `type-name-suffix`, which the author already did.
202fn checked_suffix(suffix: Option<&str>) -> Result<Option<&str>> {
203 let Some(suffix) = suffix else {
204 return Ok(None);
205 };
206 // Compare against a fixed stem, because the result must hold for every name.
207 // A suffix that adds characters to one name adds them to all names.
208 const STEM: &str = "Placeholder";
209 if to_ident(&format!("{STEM} {suffix}"), Case::Pascal).logical() != STEM {
210 return Ok(Some(suffix));
211 }
212 return Err(Error::InvalidTypeNameSuffix {
213 suffix: suffix.to_owned(),
214 hint: format!(
215 "Casing removes punctuation and separators, so `{suffix}` leaves the type name unchanged. \
216 Use a suffix with at least one letter or digit (for example \
217 `{TYPE_NAME_SUFFIX_KEY}: Alt`). To make a collision an error instead, remove \
218 `{OUTPUT_OPTIONS_KEY}.{TYPE_NAME_SUFFIX_KEY}`.",
219 ),
220 });
221}
222
223/// Add `suffix` to `ident`, and keep adding it until the result is free.
224///
225/// Repetition matters for a three-way collision. Two schemas already hold
226/// `Widget` and `WidgetAlt`, so a third must not take `WidgetAlt` again.
227///
228/// The loop ends because `suffix` adds at least one character to the identifier,
229/// which [`checked_suffix`] guarantees. Each pass therefore gives a longer name,
230/// and the supply of unclaimed names cannot run out.
231fn suffixed_ident(ident: &RustIdent, suffix: &str, claimed: &HashMap<String, String>) -> RustIdent {
232 let mut candidate = to_ident(&format!("{} {suffix}", ident.logical()), Case::Pascal);
233 while claimed.contains_key(candidate.logical()) {
234 let longer = to_ident(&format!("{} {suffix}", candidate.logical()), Case::Pascal);
235 // Defensive: `checked_suffix` rules this out. Growth is the reason the
236 // loop ends, so a non-growing step would spin forever. Stop instead.
237 if longer.logical() == candidate.logical() {
238 return candidate;
239 }
240 candidate = longer;
241 }
242 return candidate;
243}
244
245/// Build the remedy text for a schema-name collision.
246///
247/// `ident` is the Rust identifier that both schemas produce, so the example names
248/// real types and not spec names. `second` is the schema that collided.
249/// `overridden` records whether `second` already carries an `x-rust-name`. That
250/// case needs different advice, because the override itself caused the collision.
251fn collision_hint(ident: &str, second: &str, overridden: bool) -> String {
252 if overridden {
253 return format!(
254 "`{second}` already sets `{X_RUST_NAME}`, and that name also resolves to `{ident}`. \
255 Give `{second}` a name that no other schema uses.",
256 );
257 }
258 return format!(
259 "Give one of the two schemas a different Rust name with `{X_RUST_NAME}`, which records the \
260 type name the author wants. To rename every later collision instead, set \
261 `{OUTPUT_OPTIONS_KEY}.{TYPE_NAME_SUFFIX_KEY}` (for example `{TYPE_NAME_SUFFIX_KEY}: Alt`, \
262 which emits `{ident}` and `{ident}Alt`).",
263 );
264}
265
266/// Rewrite every `Named` reference in `module`'s items to honour `renames`.
267pub fn rewrite_module(module: &mut Module, renames: &HashMap<String, String>) {
268 if renames.is_empty() {
269 return;
270 }
271 for item in &mut module.items {
272 rewrite_item(item, renames);
273 }
274}
275
276/// Rewrite every `Named` reference in `service`'s operations to honour `renames`.
277pub fn rewrite_service(service: &mut Service, renames: &HashMap<String, String>) {
278 if renames.is_empty() {
279 return;
280 }
281 visit_service_types(service, &mut |ty| {
282 if let RustType::Named(name) = ty
283 && let Some(custom) = renames.get(name.as_str())
284 {
285 *name = custom.clone();
286 }
287 });
288}
289
290/// Apply `visit` to every leaf [`RustType`] referenced by the service's
291/// operation signatures (path/query/header/cookie params, request and response
292/// bodies, and response headers). Container types (`Vec`/`Map`/`Option`) are
293/// traversed to their leaf; `visit` receives the leaf in place.
294fn visit_service_types(service: &mut Service, visit: &mut dyn FnMut(&mut RustType)) {
295 for operation in &mut service.operations {
296 for param in &mut operation.path_params {
297 visit_type(&mut param.ty, visit);
298 }
299 if let Some(query) = &mut operation.query {
300 for field in &mut query.fields {
301 visit_type(&mut field.ty, visit);
302 }
303 if let Some(additional) = &mut query.additional_properties {
304 visit_type(additional, visit);
305 }
306 }
307 if let Some(headers) = &mut operation.headers {
308 for param in &mut headers.params {
309 visit_type(&mut param.ty, visit);
310 }
311 }
312 if let Some(cookies) = &mut operation.cookies {
313 for param in &mut cookies.params {
314 visit_type(&mut param.ty, visit);
315 }
316 }
317 if let Some(request) = &mut operation.request {
318 match request {
319 RequestPayload::Single(body) => visit_type(&mut body.ty, visit),
320 RequestPayload::Multipart(multipart) => {
321 for field in &mut multipart.fields {
322 visit_type(&mut field.ty, visit);
323 }
324 }
325 RequestPayload::Negotiated(negotiated) => {
326 for variant in &mut negotiated.variants {
327 visit_type(&mut variant.body.ty, visit);
328 }
329 }
330 }
331 }
332 for response in &mut operation.responses {
333 match &mut response.body {
334 Some(ResponseBody::Single(body)) => visit_type(&mut body.ty, visit),
335 Some(ResponseBody::Negotiated(negotiated)) => {
336 for variant in &mut negotiated.variants {
337 visit_type(&mut variant.body.ty, visit);
338 }
339 }
340 None => {}
341 }
342 for header in &mut response.headers {
343 visit_type(&mut header.ty, visit);
344 }
345 }
346 }
347}
348
349/// Recurse container types to their leaf, applying `visit` to the leaf in place.
350fn visit_type(ty: &mut RustType, visit: &mut dyn FnMut(&mut RustType)) {
351 match ty {
352 RustType::Vec(inner)
353 | RustType::Map(inner)
354 | RustType::Option(inner)
355 | RustType::Nullable(inner)
356 | RustType::Boxed(inner) => {
357 visit_type(inner, visit);
358 }
359 leaf => visit(leaf),
360 }
361}
362
363/// Rewrite the `Named` references reachable from a single module item.
364fn rewrite_item(item: &mut Item, renames: &HashMap<String, String>) {
365 match item {
366 Item::Struct(strukt) => {
367 for field in &mut strukt.fields {
368 rewrite_type(&mut field.ty, renames);
369 }
370 if let Some(additional) = &mut strukt.additional_properties {
371 rewrite_type(additional, renames);
372 }
373 }
374 Item::Enum(enumeration) => {
375 if let EnumKind::Union(variants) | EnumKind::AnyOf(variants) = &mut enumeration.kind {
376 for variant in variants {
377 rewrite_type(&mut variant.ty, renames);
378 }
379 }
380 }
381 Item::Alias(alias) => rewrite_type(&mut alias.ty, renames),
382 }
383}
384
385/// Replace a `Named(old)` with `Named(new)` (recursing through containers).
386fn rewrite_type(ty: &mut RustType, renames: &HashMap<String, String>) {
387 match ty {
388 RustType::Named(name) => {
389 if let Some(custom) = renames.get(name.as_str()) {
390 *name = custom.clone();
391 }
392 }
393 RustType::Vec(inner)
394 | RustType::Map(inner)
395 | RustType::Option(inner)
396 | RustType::Nullable(inner)
397 | RustType::Boxed(inner) => {
398 rewrite_type(inner, renames);
399 }
400 _ => {}
401 }
402}
403/// Fail generation if two items of `module` take one Rust type name.
404///
405/// [`TypeNames::check_emitted`] covers two component schemas that collapse onto
406/// one identifier. It cannot cover a hoisted inline schema, because such a schema
407/// has no name in `components` for the resolution pass to see. Lowering names a
408/// hoisted item after the property path that encloses it, so a component schema
409/// named after that same path takes the same name. The emitted item is what
410/// decides, so this check reads the final item names.
411///
412/// Every generation mode calls this check, including models-only generation.
413///
414/// # Errors
415///
416/// Returns one [`Error::DuplicateTypeName`] for a single duplicate name, or an
417/// [`Error::Validation`] that holds all of them.
418pub fn check_duplicate_models(module: &Module) -> Result<()> {
419 let mut diagnostics = crate::lower::validate::Diagnostics::new();
420 let mut seen: HashSet<&str> = HashSet::new();
421 for item in &module.items {
422 if !seen.insert(item.name()) {
423 diagnostics.push(Error::DuplicateTypeName {
424 name: item.name().to_owned(),
425 hint: duplicate_model_hint(item.name()),
426 });
427 }
428 }
429 return diagnostics.into_result();
430}
431
432/// Fail generation if an emitted item takes the name of a prelude type that the
433/// file names without a path.
434///
435/// This is not a duplicate-name check. A schema named `Option` emits one item,
436/// so [`check_duplicate_models`] and [`check_type_name_collisions`] both pass.
437/// The item shadows `Option` for the whole file instead, and every `Option<T>`
438/// in it then reads as that struct.
439///
440/// Every generation mode calls this check. `targets` says which names to hold,
441/// because only a server or a client writes `Result`.
442///
443/// The check reads names, not uses, so it is wider than it has to be. A spec
444/// with a schema named `Box` and no recursion writes no `Box<T>`, and it would
445/// compile. It is still rejected. Two reasons keep it that way: a false
446/// rejection is loud and has a one-line remedy in the hint, while a missed use
447/// site emits code that does not compile, which is the failure this check
448/// exists to stop. The verdict also stays put. Adding a recursive schema later
449/// cannot turn an accepted name into a broken build.
450///
451/// # Errors
452///
453/// Returns one [`Error::PreludeShadowing`] for a single name, or an
454/// [`Error::Validation`] that holds all of them.
455pub fn check_prelude_shadowing(module: &Module, targets: Targets) -> Result<()> {
456 let mut diagnostics = crate::lower::validate::Diagnostics::new();
457 let prelude = crate::emit::prelude_type_names(targets);
458 for item in &module.items {
459 let Some(shadowed) = prelude.iter().find(|entry| return entry.name == item.name()) else {
460 continue;
461 };
462 diagnostics.push(Error::PreludeShadowing {
463 name: shadowed.name.to_owned(),
464 used_for: shadowed.used_for.to_owned(),
465 hint: format!("Rename the schema with `{X_RUST_NAME}`, or with `output-options.type-name-suffix`."),
466 });
467 }
468 return diagnostics.into_result();
469}
470
471/// What claimed one crate-root type name.
472enum Claim {
473 /// A component model, or an inline schema that lowering hoisted to the crate
474 /// root. The two are one case here, because the emitted item is the same kind
475 /// of item and the remedy is the same.
476 Model,
477 /// A fixed type name that a requested generator interface emits, for example
478 /// the `Api` trait. The payload describes what emits it.
479 Reserved(&'static str),
480 /// A per-operation type. Every such name derives from the method name of the
481 /// operation that produced it, so the remedy names that operation.
482 Artifact {
483 /// What the generator emits, for example `query-parameter struct`.
484 kind: &'static str,
485 /// The `Api` method name of the operation that produced it.
486 operation: String,
487 },
488}
489
490/// Fail generation if two items that the file holds take one Rust type name.
491///
492/// In the flat layout, component models, inline schemas that lowering hoists to
493/// the crate root, per-operation types (response enums, parameter structs, and
494/// request/response body enums), and the requested generator interfaces
495/// (`reserved`, for example the `Api` trait or the `Client` struct) all share the
496/// crate root. Any two of them that take one name emit two items with that name,
497/// which does not compile. The check therefore holds one namespace and reports
498/// every claim that some earlier claim already took. Four cases reach it.
499///
500/// * A model against a reserved name. A component schema or a hoisted inline
501/// schema named `Api` or `Client` is the case.
502/// * A per-operation type against a model. The most common case is a schema named
503/// `<Op>Response`.
504/// * A per-operation type against a reserved name. An operation named `api` gives
505/// a response enum named `Api` when the suffix adds no characters.
506/// * A per-operation type against another per-operation type. A
507/// `response-type-suffix` that matches a parameter-struct suffix is one way to
508/// reach this, because it makes one operation's response enum take the name of
509/// a parameter struct.
510///
511/// Two models that take one name are the fifth pair in this namespace, and
512/// [`check_duplicate_models`] reports them. This check seeds the namespace with
513/// every model name and reports nothing for a repeat, so one clash gives one
514/// problem. That split needs the caller to run [`check_duplicate_models`] as
515/// well, which every generation mode does.
516///
517/// Rather than rename an item, generation fails, so the author resolves the clash.
518/// The `hint` of each problem names the remedy for that case.
519///
520/// Only locally emitted models count. An import-mapped model is referenced
521/// through a qualified path and cannot collide with a crate-root type.
522///
523/// # Errors
524///
525/// Returns one collision error for a single clash, or an [`Error::Validation`]
526/// that holds all of them. One run therefore reports every clash it finds.
527pub fn check_type_name_collisions(service: &Service, module: &Module, reserved: &[ReservedTypeName]) -> Result<()> {
528 let mut diagnostics = crate::lower::validate::Diagnostics::new();
529 // Seeding records each model name and reports nothing for a repeat, because
530 // `check_duplicate_models` owns that pair. This check does not enforce that the
531 // caller runs it. A caller that skips it emits two items with one name and no
532 // error, so every generation mode must call both.
533 let mut claimed: HashMap<String, Claim> = module
534 .items
535 .iter()
536 .map(|item| return (item.name().to_owned(), Claim::Model))
537 .collect();
538 for name in reserved {
539 // A model that took a reserved name keeps its `Claim::Model` entry, so the
540 // problem reports once here and not again for the reserved claim.
541 match claimed.insert(name.name.to_owned(), Claim::Reserved(name.description)) {
542 Some(Claim::Model) => {
543 claimed.insert(name.name.to_owned(), Claim::Model);
544 diagnostics.push(Error::TypeNameCollision {
545 name: name.name.to_owned(),
546 artifact: name.description.to_owned(),
547 hint: format!("rename the schema with `{X_RUST_NAME}`"),
548 });
549 }
550 Some(Claim::Reserved(_) | Claim::Artifact { .. }) | None => {}
551 }
552 }
553 for operation in &service.operations {
554 let mut claim = |name: &RustIdent, kind: &'static str| {
555 claim_artifact(&mut claimed, &mut diagnostics, name, kind, &operation.name);
556 };
557 claim(&operation.response_enum, "response enum");
558 if let Some(query) = &operation.query {
559 claim(&query.name, "query-parameter struct");
560 }
561 if let Some(headers) = &operation.headers {
562 claim(&headers.name, "header-parameter struct");
563 }
564 if let Some(cookies) = &operation.cookies {
565 claim(&cookies.name, "cookie-parameter struct");
566 }
567 match &operation.request {
568 Some(RequestPayload::Multipart(multipart)) => claim(&multipart.name, "multipart request struct"),
569 Some(RequestPayload::Negotiated(request)) => claim(&request.name, "request-body enum"),
570 Some(RequestPayload::Single(_)) | None => {}
571 }
572 for response in &operation.responses {
573 if let Some(ResponseBody::Negotiated(body)) = &response.body {
574 claim(&body.name, "response-body enum");
575 }
576 }
577 }
578 return diagnostics.into_result();
579}
580
581/// Record one per-operation type name in `claimed`, or report the clash when some
582/// earlier claim already took it.
583fn claim_artifact(
584 claimed: &mut HashMap<String, Claim>,
585 diagnostics: &mut crate::lower::validate::Diagnostics,
586 name: &RustIdent,
587 kind: &'static str,
588 operation: &RustIdent,
589) {
590 let ident = name.logical();
591 match claimed.get(ident) {
592 None => {
593 claimed.insert(
594 ident.to_owned(),
595 Claim::Artifact {
596 kind,
597 operation: operation.logical().to_owned(),
598 },
599 );
600 }
601 Some(Claim::Model) => {
602 diagnostics.push(Error::TypeNameCollision {
603 name: ident.to_owned(),
604 artifact: kind.to_owned(),
605 hint: model_clash_hint(kind),
606 });
607 }
608 Some(Claim::Reserved(description)) => {
609 diagnostics.push(Error::OperationTypeCollision {
610 name: ident.to_owned(),
611 first: format!("the {description}"),
612 second: format!("the {kind} of operation `{}`", operation.logical()),
613 hint: reserved_clash_hint(operation.logical()),
614 });
615 }
616 Some(Claim::Artifact {
617 kind: first_kind,
618 operation: first_operation,
619 }) => {
620 diagnostics.push(Error::OperationTypeCollision {
621 name: ident.to_owned(),
622 first: format!("the {first_kind} of operation `{first_operation}`"),
623 second: format!("the {kind} of operation `{}`", operation.logical()),
624 hint: artifact_clash_hint(first_operation, operation.logical()),
625 });
626 }
627 }
628}
629
630/// The remedy for two models that take one name.
631///
632/// At least one of the two is a hoisted inline schema, which carries no name of
633/// its own to override. The remedy therefore acts on the component schema that
634/// encloses it, or removes the hoist by giving the inline schema a component of
635/// its own.
636fn duplicate_model_hint(name: &str) -> String {
637 return format!(
638 "One of these comes from an inline schema that the generator hoists to the crate root, and \
639 an inline schema carries no name to override. Give the enclosing component schema a \
640 different Rust name with `{X_RUST_NAME}`, or move the inline schema into its own component \
641 schema, name that component something other than `{name}`, and refer to it with `$ref`.",
642 );
643}
644
645/// The remedy for a per-operation type that takes the name of an emitted model.
646///
647/// A response-enum clash has a second remedy, because one config key renames
648/// every response enum. The hint leads with the per-schema `x-rust-name` fix,
649/// which leaves the other response enums untouched, and offers the broad suffix
650/// after it.
651fn model_clash_hint(kind: &str) -> String {
652 if kind != "response enum" {
653 return format!("rename the schema with `{X_RUST_NAME}`");
654 }
655 let default_suffix = to_ident(DEFAULT_RESPONSE_SUFFIX, Case::Pascal);
656 return format!(
657 "give the colliding schema a different Rust name with `{X_RUST_NAME}` — a surgical, \
658 per-schema fix that leaves the other response enums untouched — or, to rename every \
659 response enum, set `{OUTPUT_OPTIONS_KEY}.{RESPONSE_TYPE_SUFFIX_KEY}` to a suffix other \
660 than the default `{}` (for example `{RESPONSE_TYPE_SUFFIX_KEY}: Resp`, which renames \
661 the enum to `<Op>Resp`)",
662 default_suffix.logical(),
663 );
664}
665
666/// The remedy for a per-operation type that takes the name of a generator
667/// interface.
668///
669/// The interface name is fixed, so only the operation side can move. Every
670/// per-operation type derives from the method name, so `x-rust-name` on the
671/// operation resolves the clash. A configured suffix can also produce this name,
672/// which the second remedy covers.
673fn reserved_clash_hint(operation: &str) -> String {
674 return format!(
675 "The generator emits this name for a requested target, so the name cannot move. Give \
676 operation `{operation}` a different method name with `{X_RUST_NAME}`, or change \
677 `{OUTPUT_OPTIONS_KEY}.{RESPONSE_TYPE_SUFFIX_KEY}` if that suffix produced the name.",
678 );
679}
680
681/// The remedy for two per-operation types that take one name.
682///
683/// One operation that produces both names is a different problem from two
684/// operations that produce one name. No method name can separate two types of one
685/// operation, so that case names the suffix that made them equal. Two operations
686/// take the per-operation `x-rust-name` remedy, because every per-operation type
687/// derives from the method name.
688fn artifact_clash_hint(first_operation: &str, second_operation: &str) -> String {
689 if first_operation == second_operation {
690 return format!(
691 "Both names belong to operation `{first_operation}`, so no method name can separate \
692 them. Set `{OUTPUT_OPTIONS_KEY}.{RESPONSE_TYPE_SUFFIX_KEY}` to a suffix that no \
693 parameter-struct or body-enum name already ends with.",
694 );
695 }
696 return format!(
697 "Every per-operation type derives from the method name of its operation. Give operation \
698 `{first_operation}` or operation `{second_operation}` a different method name with \
699 `{X_RUST_NAME}`.",
700 );
701}
702
703#[cfg(test)]
704mod tests {
705 use super::*;
706
707 #[test]
708 fn suffix_with_identifier_characters_is_accepted() {
709 for suffix in ["Alt", "2", "a", "-v2", "_alt"] {
710 let outcome = checked_suffix(Some(suffix));
711 assert!(
712 matches!(outcome, Ok(Some(kept)) if kept == suffix),
713 "`{suffix}` adds characters to a type name and must be accepted",
714 );
715 }
716 }
717
718 #[test]
719 fn absent_suffix_stays_absent() {
720 assert!(matches!(checked_suffix(None), Ok(None)));
721 }
722
723 #[test]
724 fn suffix_without_identifier_characters_is_rejected() {
725 // Casing drops each of these, so the suffix cannot resolve a collision.
726 // An unbounded search for a free name would otherwise never end.
727 for suffix in ["", " ", "-", "_", "...", "-_-"] {
728 let outcome = checked_suffix(Some(suffix));
729 assert!(
730 matches!(outcome, Err(Error::InvalidTypeNameSuffix { .. })),
731 "`{suffix}` adds nothing to a type name and must be rejected",
732 );
733 }
734 }
735
736 #[test]
737 fn rejected_suffix_names_both_remedies() {
738 let Err(err) = checked_suffix(Some("-")) else {
739 panic!("`-` must be rejected");
740 };
741 let Error::InvalidTypeNameSuffix { hint, .. } = &err else {
742 panic!("expected an InvalidTypeNameSuffix, got {err:?}");
743 };
744 // The hint must show how to make the suffix work, and how to go back to
745 // an error for a collision.
746 assert!(hint.contains(TYPE_NAME_SUFFIX_KEY), "hint names the key: {hint}");
747 assert!(hint.contains("Alt"), "hint gives a working example: {hint}");
748 assert!(hint.contains("remove"), "hint offers the error mode: {hint}");
749 // The message states the problem. The console prints the hint under it,
750 // so `Display` must not repeat the hint.
751 assert!(!err.to_string().contains(hint.as_str()));
752 }
753
754 #[test]
755 fn suffixed_ident_grows_until_the_name_is_free() {
756 let mut claimed: HashMap<String, String> = HashMap::new();
757 claimed.insert("OrderItem".to_owned(), "order-item".to_owned());
758 claimed.insert("OrderItemAlt".to_owned(), "orderItem".to_owned());
759 // `OrderItem` and `OrderItemAlt` are taken, so a third collision must
760 // repeat the suffix instead of reusing `OrderItemAlt`.
761 let ident = suffixed_ident(&to_ident("order-item", Case::Pascal), "Alt", &claimed);
762 assert_eq!(ident.logical(), "OrderItemAltAlt");
763 }
764}