Skip to main content

oapi_codegen/
naming.rs

1//! Conversion of OpenAPI names into valid, idiomatic Rust identifiers and
2//! derivation of per-operation artifact names.
3
4use proc_macro2::Ident;
5use proc_macro2::Span;
6
7/// The `x-rust-name` extension key: override a generated identifier with a
8/// caller-supplied name. It applies to a top-level schema (the type name), a
9/// property (the field name), an operation (the method name, and so the name of
10/// every artifact derived from it), and a `servers:` entry (the server URL name).
11/// Shared so every consumer and any user-facing message names the same key.
12pub(crate) const X_RUST_NAME: &str = "x-rust-name";
13
14/// `snake_case` / `UpperCamelCase` conversion used by [`to_ident`].
15mod casing;
16
17/// Derivation of the Rust names of the artifacts generated for an operation.
18pub mod operations;
19
20/// A Rust identifier together with whether it must be emitted as a raw
21/// identifier (`r#name`).
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct RustIdent {
24    /// The logical identifier text (without any `r#` prefix).
25    text: String,
26    /// Whether the identifier must be emitted in raw form.
27    raw: bool,
28}
29
30impl RustIdent {
31    /// The logical name as serde would serialize it (no `r#`).
32    pub fn logical(&self) -> &str {
33        return &self.text;
34    }
35
36    /// Build a [`proc_macro2::Ident`] for use in `quote!`.
37    pub fn to_token(&self) -> Ident {
38        let ident = if self.raw {
39            Ident::new_raw(&self.text, Span::call_site())
40        } else {
41            Ident::new(&self.text, Span::call_site())
42        };
43        return ident;
44    }
45}
46
47/// Casing to apply when converting a name.
48#[derive(Debug, Clone, Copy)]
49pub enum Case {
50    /// `PascalCase` — used for types and enum variants.
51    Pascal,
52    /// `snake_case` — used for struct fields.
53    Snake,
54    /// `SCREAMING_SNAKE_CASE` — used for `const`/`static` items.
55    ScreamingSnake,
56}
57
58/// Convert an arbitrary OpenAPI name into a valid Rust identifier in the
59/// requested case, escaping keywords and leading digits.
60pub fn to_ident(name: &str, case: Case) -> RustIdent {
61    let cased = match case {
62        Case::Pascal => casing::to_upper_camel_case(name),
63        Case::Snake => casing::to_snake_case(name),
64        Case::ScreamingSnake => casing::to_screaming_snake_case(name),
65    };
66
67    let cased = if cased.is_empty() { "Unnamed".to_owned() } else { cased };
68
69    // An identifier may not start with a digit.
70    let starts_with_digit = cased.chars().next().map(char::is_numeric).unwrap_or(false);
71    let cased = if starts_with_digit { format!("_{cased}") } else { cased };
72
73    match classify_ident(&cased) {
74        IdentForm::Plain => {
75            return RustIdent {
76                text: cased,
77                raw: false,
78            };
79        }
80        IdentForm::Raw => {
81            return RustIdent { text: cased, raw: true };
82        }
83        IdentForm::Suffix => {
84            return RustIdent {
85                text: format!("{cased}_"),
86                raw: false,
87            };
88        }
89    }
90}
91
92/// Compute the serde `rename` value for a member, given its wire name and the
93/// chosen Rust identifier. Returns `None` when no rename attribute is needed.
94pub fn rename_for(wire: &str, ident: &RustIdent) -> Option<String> {
95    if ident.logical() == wire {
96        return None;
97    }
98    return Some(wire.to_owned());
99}
100
101/// Make `ident` unique among the identifiers in `seen`. A collision gets the
102/// lowest free numeric suffix: `Foo`, `Foo2`, `Foo3`, and so on. The function
103/// adds the chosen identifier to `seen`. The key is the logical identifier text,
104/// so `foo` and `Foo` count as different identifiers.
105///
106/// Only enum variants use this function. Two variants inside one enum can
107/// collapse onto one Rust identifier. For example, `foo-bar` and `fooBar` both
108/// become `FooBar`.
109///
110/// Top-level type names do not use this function. A numeric suffix there would
111/// pick a public type name for the author, and the generator fails fast instead.
112/// See [`crate::lower::rename::type_renames`].
113pub fn deconflict_ident(ident: RustIdent, seen: &mut std::collections::HashSet<String>) -> RustIdent {
114    if seen.insert(ident.logical().to_owned()) {
115        return ident;
116    }
117    let mut suffix: u32 = 2;
118    loop {
119        let candidate = to_ident(&format!("{} {suffix}", ident.logical()), Case::Pascal);
120        if seen.insert(candidate.logical().to_owned()) {
121            return candidate;
122        }
123        suffix += 1;
124    }
125}
126
127/// How a candidate identifier string must be emitted to be valid Rust.
128enum IdentForm {
129    /// Usable verbatim.
130    Plain,
131    /// A keyword that must be written as a raw identifier (`r#name`).
132    Raw,
133    /// A keyword that cannot be raw and must be escaped with a trailing `_`.
134    Suffix,
135}
136
137/// Classify `s` using `syn`'s identifier grammar so the keyword set stays in
138/// sync with the compiler as `syn` is updated — there is no runtime reflection
139/// for Rust keywords, but `syn` already encodes the grammar we depend on.
140///
141/// `syn` cannot know the target edition, so edition-2024 reserved words that it
142/// still accepts as plain identifiers are escaped explicitly via
143/// [`is_edition_2024_keyword`].
144fn classify_ident(s: &str) -> IdentForm {
145    if syn::parse_str::<syn::Ident>(s).is_ok() && !is_edition_2024_keyword(s) {
146        return IdentForm::Plain;
147    }
148    if syn::parse_str::<syn::Ident>(&format!("r#{s}")).is_ok() {
149        return IdentForm::Raw;
150    }
151    return IdentForm::Suffix;
152}
153
154/// Reserved words introduced by newer editions that `syn`'s edition-agnostic
155/// parser still accepts as plain identifiers. They are raw-escaped so generated
156/// code compiles under edition 2024 and later.
157fn is_edition_2024_keyword(s: &str) -> bool {
158    return matches!(s, "gen");
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164
165    #[test]
166    fn pascal_case_types() {
167        let cases = [
168            ("ErrorResponse", "ErrorResponse", false),
169            ("payment_form", "PaymentForm", false),
170            ("da", "Da", false),
171            ("PET_SHOP_SIGNUP_REQUEST", "PetShopSignupRequest", false),
172        ];
173        for (input, expected, raw) in cases {
174            let ident = to_ident(input, Case::Pascal);
175            assert_eq!(ident.logical(), expected, "input {input}");
176            assert_eq!(ident.raw, raw, "input {input}");
177        }
178    }
179
180    #[test]
181    fn snake_case_fields_and_keywords() {
182        let ty = to_ident("type", Case::Snake);
183        assert_eq!(ty.logical(), "type");
184        assert!(ty.raw, "`type` should be a raw identifier");
185        assert_eq!(rename_for("type", &ty), None);
186
187        let email = to_ident("customer_email", Case::Snake);
188        assert_eq!(email.logical(), "customer_email");
189        assert!(!email.raw);
190        assert_eq!(rename_for("customer_email", &email), None);
191    }
192
193    #[test]
194    fn rename_when_casing_differs() {
195        let ident = to_ident("da", Case::Pascal);
196        assert_eq!(rename_for("da", &ident), Some("da".to_owned()));
197    }
198
199    #[test]
200    fn keyword_that_cannot_be_raw_is_suffixed() {
201        let ident = to_ident("self", Case::Snake);
202        assert_eq!(ident.logical(), "self_");
203        assert!(!ident.raw);
204    }
205
206    #[test]
207    fn edition_2024_keyword_is_raw() {
208        // `gen` is reserved in edition 2024 but `syn`'s edition-agnostic parser
209        // accepts it as a plain identifier, so we escape it ourselves.
210        let ident = to_ident("gen", Case::Snake);
211        assert_eq!(ident.logical(), "gen");
212        assert!(ident.raw, "`gen` should be a raw identifier under edition 2024");
213    }
214
215    #[test]
216    fn deconflict_ident_suffixes_collisions() {
217        let mut seen = std::collections::HashSet::new();
218        // Enum member names that collapse onto one identifier get the lowest
219        // free numeric suffix, in the order the lowering sees them.
220        let first = deconflict_ident(to_ident("in-progress", Case::Pascal), &mut seen);
221        let second = deconflict_ident(to_ident("inProgress", Case::Pascal), &mut seen);
222        let third = deconflict_ident(to_ident("In_Progress", Case::Pascal), &mut seen);
223        assert_eq!(first.logical(), "InProgress");
224        assert_eq!(second.logical(), "InProgress2");
225        assert_eq!(third.logical(), "InProgress3");
226        // A different identifier stays as it is.
227        let other = deconflict_ident(to_ident("done", Case::Pascal), &mut seen);
228        assert_eq!(other.logical(), "Done");
229    }
230
231    #[test]
232    fn casing_word_boundaries() {
233        // Representative word-boundary cases: camelCase, acronyms, SHOUTY, and mixed separators.
234        let snake = [
235            ("CamelCase", "camel_case"),
236            ("XMLHttpRequest", "xml_http_request"),
237            ("FIELD_NAME11", "field_name11"),
238            (
239                "this-contains_ ALLKinds OfWord_Boundaries",
240                "this_contains_all_kinds_of_word_boundaries",
241            ),
242        ];
243        for (input, expected) in snake {
244            assert_eq!(casing::to_snake_case(input), expected, "snake {input}");
245        }
246
247        let pascal = [
248            ("CamelCase", "CamelCase"),
249            ("XMLHttpRequest", "XmlHttpRequest"),
250            ("SHOUTY_SNAKE_CASE", "ShoutySnakeCase"),
251            (
252                "this-contains_ ALLKinds OfWord_Boundaries",
253                "ThisContainsAllKindsOfWordBoundaries",
254            ),
255        ];
256        for (input, expected) in pascal {
257            assert_eq!(casing::to_upper_camel_case(input), expected, "pascal {input}");
258        }
259    }
260}