Skip to content

Commit fa39b41

Browse files
authored
fix: prelude-shadowing names (#90)
1 parent a415d6b commit fa39b41

16 files changed

Lines changed: 871 additions & 2 deletions

crates/oapi-codegen/src/console.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,7 @@ fn hints_for(err: &Error) -> Vec<String> {
278278
| Error::UnsupportedContentType { hint, .. }
279279
| Error::TypeNameCollision { hint, .. }
280280
| Error::DuplicateTypeName { hint, .. }
281+
| Error::PreludeShadowing { hint, .. }
281282
| Error::OperationTypeCollision { hint, .. }
282283
| Error::SchemaNameCollision { hint, .. }
283284
| Error::RecursiveAlias { hint, .. }

crates/oapi-codegen/src/emit/mod.rs

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,15 +89,73 @@ pub fn emit_module(module: &Module, server_urls: Option<&ServerUrls>) -> Result<
8989

9090
/// Which generator interfaces to emit alongside the shared per-operation types.
9191
///
92-
/// At least one field is set whenever [`emit_flat`] is called.
93-
#[derive(Debug, Clone, Copy)]
92+
/// At least one field is set whenever [`emit_flat`] is called. The default, with
93+
/// no field set, is models-only generation.
94+
#[derive(Debug, Clone, Copy, Default)]
9495
pub struct Targets {
9596
/// Emit the axum server interface.
9697
pub server: bool,
9798
/// Emit the blocking `reqwest` client.
9899
pub client: bool,
99100
}
100101

102+
/// A Rust prelude type that the emitted file names without a path, and what
103+
/// needs it.
104+
#[derive(Debug, Clone, Copy)]
105+
pub struct PreludeTypeName {
106+
/// The prelude identifier, for example `Option`.
107+
pub name: &'static str,
108+
/// What generated code can name it for, for example `every optional field`,
109+
/// used in the shadowing error.
110+
pub used_for: &'static str,
111+
}
112+
113+
/// The prelude types the requested `targets` name without a path.
114+
///
115+
/// A generated type of one of these names does not duplicate any item, so no
116+
/// collision check sees it. It shadows the prelude inside the file, and every
117+
/// use of the shadowed type stops compiling, so
118+
/// [`crate::lower::check_prelude_shadowing`] rejects it up front.
119+
///
120+
/// `Ok`, `Err`, `Some`, and `None` are absent, and belong in no list, but the
121+
/// reason is narrow. Those name values, and a *braced* `struct`, an `enum`, and
122+
/// an alias each take a type name only. A tuple or unit `struct` would take the
123+
/// value name too, and a model named `Ok` would then hide the prelude variant.
124+
/// The emitter writes `pub struct #name {..}` at every site, and
125+
/// `every_generated_struct_is_braced` holds it there. Fixture
126+
/// `combined_prelude_value_names` compiles the adversarial case: an operation
127+
/// references each of the four names, so none is pruned, and a server and a
128+
/// client then write `Ok(..)`, `Err(..)`, `Some(..)`, and `None` without a path
129+
/// beside models of those names.
130+
pub fn prelude_type_names(targets: Targets) -> Vec<PreludeTypeName> {
131+
// Models carry the first four whichever target asks for them.
132+
let mut names = vec![
133+
PreludeTypeName {
134+
name: "Option",
135+
used_for: "every optional field",
136+
},
137+
PreludeTypeName {
138+
name: "String",
139+
used_for: "every string field",
140+
},
141+
PreludeTypeName {
142+
name: "Vec",
143+
used_for: "every array field",
144+
},
145+
PreludeTypeName {
146+
name: "Box",
147+
used_for: "the indirection a recursive schema takes",
148+
},
149+
];
150+
if targets.server || targets.client {
151+
names.push(PreludeTypeName {
152+
name: "Result",
153+
used_for: "every generated method signature",
154+
});
155+
}
156+
return names;
157+
}
158+
101159
/// A fixed type name the generator emits at the crate root for a given target,
102160
/// which a component-schema model must not collide with.
103161
#[derive(Debug, Clone, Copy)]

crates/oapi-codegen/src/error.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,24 @@ pub enum Error {
220220
hint: String,
221221
},
222222

223+
/// A generated type took the name of a Rust prelude type that the emitted
224+
/// code writes unqualified, such as `Option` or `Vec`.
225+
///
226+
/// The name does not duplicate an emitted item, so no other collision check
227+
/// sees it. It shadows the prelude inside the generated file instead, and
228+
/// every use of the shadowed type there stops compiling.
229+
PreludeShadowing {
230+
/// The Rust type name that shadows the prelude.
231+
name: String,
232+
/// What generated code can name the shadowed type for, for example
233+
/// `every optional field`. The check reads names, not uses, so the file
234+
/// at hand does not have to hold one.
235+
used_for: String,
236+
/// How to resolve the clash. Rendered by the console as a hint, and not
237+
/// by `Display`, so the console does not print it twice.
238+
hint: String,
239+
},
240+
223241
/// Two emitted items took one Rust type name, and at least one of them came
224242
/// from an inline schema that lowering hoisted to the crate root.
225243
///
@@ -438,6 +456,12 @@ impl std::fmt::Display for Error {
438456
"generated {artifact} `{name}` collides with a component schema of the same name"
439457
);
440458
}
459+
Error::PreludeShadowing { name, used_for, .. } => {
460+
return write!(
461+
f,
462+
"generated type `{name}` shadows the Rust prelude type of that name, which generated code can name without a path for {used_for}"
463+
);
464+
}
441465
Error::DuplicateTypeName { name, .. } => {
442466
return write!(f, "two generated items both take the Rust type name `{name}`");
443467
}
@@ -511,6 +535,7 @@ impl std::error::Error for Error {
511535
| Error::SchemaDepthExceeded { .. }
512536
| Error::TypeNameCollision { .. }
513537
| Error::DuplicateTypeName { .. }
538+
| Error::PreludeShadowing { .. }
514539
| Error::OperationTypeCollision { .. }
515540
| Error::SchemaNameCollision { .. }
516541
| Error::RecursiveAlias { .. }

crates/oapi-codegen/src/lib.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,12 +87,14 @@ pub fn generate(spec_path: &Path, config: &Config) -> Result<String> {
8787
client: want_client,
8888
};
8989
lower::check_type_name_collisions(&service, &module, &emit::reserved_type_names(targets))?;
90+
lower::check_prelude_shadowing(&module, targets)?;
9091
return emit::emit_flat(&module, &service, server_urls.as_ref(), targets);
9192
}
9293
// Models-only generation prunes nothing, so the module holds every schema and
9394
// every collision reports.
9495
names.check_emitted(&module)?;
9596
lower::check_duplicate_models(&module)?;
97+
lower::check_prelude_shadowing(&module, emit::Targets::default())?;
9698
lower::box_recursive_types(&mut module)?;
9799
return emit::emit_module(&module, server_urls.as_ref());
98100
}
@@ -117,6 +119,7 @@ pub fn generate_models_string(spec_path: &Path) -> Result<String> {
117119
// Every schema becomes an item here, so every collision reaches the file.
118120
names.check_emitted(&module)?;
119121
lower::check_duplicate_models(&module)?;
122+
lower::check_prelude_shadowing(&module, emit::Targets::default())?;
120123
lower::box_recursive_types(&mut module)?;
121124
let code = emit::emit_module(&module, None)?;
122125
return Ok(code);

crates/oapi-codegen/src/lower/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ pub use crate::lower::prune::prune_unused_models;
2121
pub use crate::lower::recurse::box_recursive_types;
2222
pub use crate::lower::rename::TypeNames;
2323
pub use crate::lower::rename::check_duplicate_models;
24+
pub use crate::lower::rename::check_prelude_shadowing;
2425
pub use crate::lower::rename::check_type_name_collisions;
2526
pub use crate::lower::rename::rewrite_service;
2627
pub use crate::lower::rename::type_renames;

crates/oapi-codegen/src/lower/rename.rs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ use crate::config::OUTPUT_OPTIONS_KEY;
2424
use crate::config::RESPONSE_TYPE_SUFFIX_KEY;
2525
use crate::config::TYPE_NAME_SUFFIX_KEY;
2626
use crate::emit::ReservedTypeName;
27+
use crate::emit::Targets;
2728
use crate::error::Error;
2829
use crate::error::Result;
2930
use crate::ir::EnumKind;
@@ -422,6 +423,45 @@ pub fn check_duplicate_models(module: &Module) -> Result<()> {
422423
return diagnostics.into_result();
423424
}
424425

426+
/// Fail generation if an emitted item takes the name of a prelude type that the
427+
/// file names without a path.
428+
///
429+
/// This is not a duplicate-name check. A schema named `Option` emits one item,
430+
/// so [`check_duplicate_models`] and [`check_type_name_collisions`] both pass.
431+
/// The item shadows `Option` for the whole file instead, and every `Option<T>`
432+
/// in it then reads as that struct.
433+
///
434+
/// Every generation mode calls this check. `targets` says which names to hold,
435+
/// because only a server or a client writes `Result`.
436+
///
437+
/// The check reads names, not uses, so it is wider than it has to be. A spec
438+
/// with a schema named `Box` and no recursion writes no `Box<T>`, and it would
439+
/// compile. It is still rejected. Two reasons keep it that way: a false
440+
/// rejection is loud and has a one-line remedy in the hint, while a missed use
441+
/// site emits code that does not compile, which is the failure this check
442+
/// exists to stop. The verdict also stays put. Adding a recursive schema later
443+
/// cannot turn an accepted name into a broken build.
444+
///
445+
/// # Errors
446+
///
447+
/// Returns one [`Error::PreludeShadowing`] for a single name, or an
448+
/// [`Error::Validation`] that holds all of them.
449+
pub fn check_prelude_shadowing(module: &Module, targets: Targets) -> Result<()> {
450+
let mut diagnostics = crate::lower::validate::Diagnostics::new();
451+
let prelude = crate::emit::prelude_type_names(targets);
452+
for item in &module.items {
453+
let Some(shadowed) = prelude.iter().find(|entry| return entry.name == item.name()) else {
454+
continue;
455+
};
456+
diagnostics.push(Error::PreludeShadowing {
457+
name: shadowed.name.to_owned(),
458+
used_for: shadowed.used_for.to_owned(),
459+
hint: format!("Rename the schema with `{X_RUST_NAME}`, or with `output-options.type-name-suffix`."),
460+
});
461+
}
462+
return diagnostics.into_result();
463+
}
464+
425465
/// What claimed one crate-root type name.
426466
enum Claim {
427467
/// A component model, or an inline schema that lowering hoisted to the crate

crates/oapi-codegen/tests/coverage.rs

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,21 @@ const TEST_TABLE: &[Feature] = &[
338338
status: Status::Supported,
339339
fixture: Some("type_name_collisions"),
340340
},
341+
Feature {
342+
element: "naming.prelude-value-names",
343+
status: Status::Supported,
344+
fixture: Some("prelude_value_names"),
345+
},
346+
Feature {
347+
element: "naming.prelude-type-names",
348+
status: Status::Unsupported,
349+
fixture: Some("unsupported_prelude_shadowing"),
350+
},
351+
Feature {
352+
element: "naming.prelude-type-names.target-scoped",
353+
status: Status::Supported,
354+
fixture: Some("prelude_result_name"),
355+
},
341356
// Document-level (server/client generation). The axum server generator now
342357
// covers a slice of paths/parameters/requestBody/responses; the blocking
343358
// reqwest client generator additionally covers securitySchemes. Those slices
@@ -501,6 +516,7 @@ const CLIENT_UNSUPPORTED_FIXTURES: &[&str] = &[
501516
/// flat crate-root layout in which the server and client share one file and the
502517
/// same per-operation types alongside the component models.
503518
const COMBINED_FIXTURES: &[&str] = &[
519+
"combined_prelude_value_names",
504520
"combined_server_client",
505521
"combined_response_name_collision",
506522
"combined_x_rust_derive",
@@ -630,6 +646,8 @@ generated_tests!(
630646
number_formats,
631647
object_additional_properties,
632648
object_deny_unknown_fields,
649+
prelude_result_name,
650+
prelude_value_names,
633651
schema_defaults,
634652
object_nested_inline,
635653
object_optional_required,
@@ -1157,6 +1175,7 @@ macro_rules! combined_generated_tests {
11571175
}
11581176

11591177
combined_generated_tests!(
1178+
combined_prelude_value_names,
11601179
combined_server_client,
11611180
combined_response_name_collision,
11621181
combined_x_rust_derive,
@@ -1426,6 +1445,82 @@ fn operation_types_that_take_client_reserved_names_fail() {
14261445
);
14271446
}
14281447

1448+
/// A model that takes the name of a prelude type the file writes without a path
1449+
/// must stop generation. Such a model emits one item and duplicates nothing, so
1450+
/// no other collision check sees it. It shadows the prelude instead, and the
1451+
/// generated file stops compiling.
1452+
#[test]
1453+
fn a_model_that_shadows_a_prelude_type_fails() {
1454+
let fixture = tests_dir().join("fixtures").join("unsupported_prelude_shadowing.yaml");
1455+
let err = oapi_codegen::generate_models_string(&fixture).expect_err("a model named `Option` must stop generation");
1456+
let oapi_codegen::Error::Validation { problems } = &err else {
1457+
panic!("expected an aggregated Validation error, got: {err:?}");
1458+
};
1459+
// One run reports every shadowed name, so the author fixes them together.
1460+
assert_eq!(problems.len(), 4, "expected every shadowed name, got: {problems:?}");
1461+
let report = err.to_string();
1462+
for name in ["Option", "String", "Vec", "Box"] {
1463+
assert!(report.contains(name), "the report must name `{name}`, got: {report}");
1464+
}
1465+
}
1466+
1467+
/// The remedy must name a rename, because the prelude name is fixed.
1468+
#[test]
1469+
fn the_prelude_shadowing_hint_offers_a_rename() {
1470+
let fixture = tests_dir().join("fixtures").join("unsupported_prelude_shadowing.yaml");
1471+
let err = oapi_codegen::generate_models_string(&fixture).expect_err("a shadowing model must stop generation");
1472+
let oapi_codegen::Error::Validation { problems } = &err else {
1473+
panic!("expected an aggregated Validation error, got: {err:?}");
1474+
};
1475+
let first = problems.first().expect("at least one problem");
1476+
let oapi_codegen::Error::PreludeShadowing { hint, used_for, .. } = first else {
1477+
panic!("expected PreludeShadowing, got: {first:?}");
1478+
};
1479+
assert!(
1480+
hint.contains("x-rust-name"),
1481+
"the hint must offer a rename, got: {hint}"
1482+
);
1483+
assert!(!used_for.is_empty(), "the problem must say what needs the name");
1484+
}
1485+
1486+
/// A prelude name is only held when the run writes it. Models name no `Result`,
1487+
/// so a model of that name generates. A server writes `Result` in every method
1488+
/// signature, so the same document fails there.
1489+
#[test]
1490+
fn prelude_name_check_is_target_scoped() {
1491+
let fixture = tests_dir().join("fixtures").join("prelude_result_name.yaml");
1492+
oapi_codegen::generate_models_string(&fixture).expect("models alone name no `Result`");
1493+
let err = oapi_codegen::generate(&fixture, &server_config()).expect_err("a server writes `Result` in every method");
1494+
assert!(
1495+
matches!(err, oapi_codegen::Error::PreludeShadowing { .. }),
1496+
"expected PreludeShadowing, got: {err:?}",
1497+
);
1498+
}
1499+
1500+
/// A value name is not a type name. `Ok`, `Err`, `Some`, and `None` name values,
1501+
/// and a *braced* `struct` takes a type name only, so those four must generate.
1502+
/// The generated file under `tests/generated` compiles as proof.
1503+
///
1504+
/// This covers models-only output. `combined_prelude_value_names` covers the
1505+
/// harder case, where a server and a client write `Ok(..)`, `Err(..)`, `Some(..)`,
1506+
/// and `None` around models of those names. Both rest on
1507+
/// `every_generated_struct_is_braced`.
1508+
#[test]
1509+
fn a_model_named_after_a_prelude_value_generates() {
1510+
let fixture = tests_dir().join("fixtures").join("prelude_value_names.yaml");
1511+
let code = oapi_codegen::generate_models_string(&fixture).expect("a prelude value name is free");
1512+
assert!(
1513+
code.contains("pub struct Ok") && code.contains("pub struct None"),
1514+
"the models must keep their names, got: {code}",
1515+
);
1516+
// The same file still writes `Option` for an optional field, which proves the
1517+
// four names left it alone.
1518+
assert!(
1519+
code.contains("pub maybe: Option<String>"),
1520+
"the file must still name the prelude `Option`, got: {code}",
1521+
);
1522+
}
1523+
14291524
/// A reserved name is only reserved when its target is requested. The server-only
14301525
/// `Api` trait must not block an operation whose response enum takes that name in a
14311526
/// client-only run.
@@ -2108,6 +2203,48 @@ fn fixtures_and_test_table_agree() {
21082203
}
21092204
}
21102205

2206+
/// Every generated `struct` must be braced, never a tuple or a unit `struct`.
2207+
///
2208+
/// This is what keeps `Ok`, `Err`, `Some`, and `None` free as schema names. A
2209+
/// braced `struct` takes a type name only. A tuple or unit `struct` takes the
2210+
/// value name of that identifier too, so a model named `Ok` would then shadow the
2211+
/// prelude variant, and every `Ok(..)` the generators write would stop compiling.
2212+
///
2213+
/// The invariant is implicit in the emitter, which writes `pub struct #name {..}`
2214+
/// at every site. This test states it, so a newtype added later fails here rather
2215+
/// than in a consumer's build.
2216+
#[test]
2217+
fn every_generated_struct_is_braced() {
2218+
let dir = tests_dir().join("generated");
2219+
let entries = std::fs::read_dir(&dir).expect("read generated dir");
2220+
let mut checked = 0_usize;
2221+
for entry in entries {
2222+
let path = entry.expect("dir entry").path();
2223+
if !path.extension().is_some_and(|ext| {
2224+
return ext == "rs";
2225+
}) {
2226+
continue;
2227+
}
2228+
let source = std::fs::read_to_string(&path).expect("read generated file");
2229+
for line in source.lines() {
2230+
let Some(rest) = line.trim_start().strip_prefix("pub struct ") else {
2231+
continue;
2232+
};
2233+
checked += 1;
2234+
// A braced struct opens its body, or its generics, before anything
2235+
// else. A tuple struct opens `(` and a unit struct ends at `;`.
2236+
let tail = rest.trim_end();
2237+
assert!(
2238+
tail.ends_with('{'),
2239+
"`{}` in {} is not a braced struct, which would take a prelude value name",
2240+
tail,
2241+
path.display(),
2242+
);
2243+
}
2244+
}
2245+
assert!(checked > 0, "no generated struct was checked, so the scan is broken");
2246+
}
2247+
21112248
/// The test table itself must be well-formed: unique elements, and every
21122249
/// supported/unsupported row backed by a fixture.
21132250
#[test]

0 commit comments

Comments
 (0)