Skip to main content

openapi_trait_shared/codegen/
idents.rs

1//! Keyword-safe, validated construction of Rust identifiers from `OpenAPI` names.
2//!
3//! `OpenAPI` `operationId`s and parameter names are free-form strings, but they
4//! are used to name generated Rust methods, struct fields, and types. Feeding an
5//! arbitrary string into [`format_ident!`](quote::format_ident) (i.e.
6//! [`proc_macro2::Ident::new`]) *panics* when the string is not a legal
7//! identifier — for example an op named `type`/`async` (a keyword) or one
8//! starting with a digit. These helpers turn that panic into either a raw
9//! identifier (`type` → `r#type`) or a clear diagnostic the caller can route
10//! through [`super::operations::Diagnostics`].
11
12use heck::{ToPascalCase, ToSnakeCase};
13use proc_macro2::{Ident, Span};
14
15/// Rust keywords that are legal as *raw* identifiers (`r#keyword`).
16///
17/// Covers strict and reserved keywords. Deliberately excludes the four in
18/// [`NON_RAW_KEYWORDS`] (which cannot be raw) and the contextual keyword
19/// `union` (already a valid plain identifier).
20const RAW_SAFE_KEYWORDS: &[&str] = &[
21    // strict keywords
22    "as", "async", "await", "break", "const", "continue", "dyn", "else", "enum", "extern", "false",
23    "fn", "for", "if", "impl", "in", "let", "loop", "match", "mod", "move", "mut", "pub", "ref",
24    "return", "static", "struct", "trait", "true", "type", "unsafe", "use", "where", "while",
25    // reserved for future use
26    "abstract", "become", "box", "do", "final", "macro", "override", "priv", "try", "typeof",
27    "unsized", "virtual", "yield",
28];
29
30/// Keywords that cannot be expressed as raw identifiers; using one requires
31/// renaming the source `OpenAPI` construct.
32const NON_RAW_KEYWORDS: &[&str] = &["crate", "self", "super", "Self"];
33
34/// True when `s` is a legal *plain* Rust identifier (ASCII rule).
35///
36/// `OpenAPI` names are realistically ASCII once `heck` has normalised them, so a
37/// simple ASCII check keeps us dependency-free. Keywords are intentionally *not*
38/// rejected here — keyword handling is the caller's job via [`safe_ident`].
39fn is_plain_ident(s: &str) -> bool {
40    if s.is_empty() || s == "_" {
41        return false;
42    }
43    let mut chars = s.chars();
44    let first = chars.next().expect("non-empty checked above");
45    if !(first.is_ascii_alphabetic() || first == '_') {
46        return false;
47    }
48    chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
49}
50
51/// Turn an already case-converted name into a keyword-safe [`Ident`].
52///
53/// Keywords become raw identifiers; names that cannot be a Rust identifier at
54/// all (empty, leading digit, illegal characters, or a non-raw-able keyword)
55/// return `Err` with a human-readable reason. `original` and `role` are used
56/// only to build that message (e.g. `role = "operationId"`).
57///
58/// # Errors
59///
60/// Returns `Err(reason)` when `converted` cannot name a Rust item.
61pub fn safe_ident(converted: &str, original: &str, role: &str) -> Result<Ident, String> {
62    if converted.is_empty() {
63        return Err(format!(
64            "{role} `{original}` produces an empty Rust identifier; rename it"
65        ));
66    }
67    if NON_RAW_KEYWORDS.contains(&converted) {
68        return Err(format!(
69            "{role} `{original}` maps to the reserved Rust keyword `{converted}`, which cannot be used as an identifier (not even as a raw identifier); rename it"
70        ));
71    }
72    if RAW_SAFE_KEYWORDS.contains(&converted) {
73        return Ok(Ident::new_raw(converted, Span::call_site()));
74    }
75    if !is_plain_ident(converted) {
76        return Err(format!(
77            "{role} `{original}` produces the invalid Rust identifier `{converted}`; identifiers must start with a letter or underscore and contain only letters, digits, and underscores"
78        ));
79    }
80    Ok(Ident::new(converted, Span::call_site()))
81}
82
83/// Keyword-safe method identifier for an `operationId` (snake-cased).
84///
85/// # Errors
86///
87/// See [`safe_ident`].
88pub fn method_ident(operation_id: &str) -> Result<Ident, String> {
89    safe_ident(&operation_id.to_snake_case(), operation_id, "operationId")
90}
91
92/// Keyword-safe struct-field identifier for a parameter name (snake-cased).
93///
94/// # Errors
95///
96/// See [`safe_ident`].
97pub fn field_ident(param_name: &str) -> Result<Ident, String> {
98    safe_ident(&param_name.to_snake_case(), param_name, "parameter")
99}
100
101/// Validate that an `operationId` yields a usable `PascalCase` base for generated
102/// type names (`{Base}Request`, `{Base}Response`, `{Base}Auth`, …).
103///
104/// A non-empty suffix is always appended by callers, so a keyword base is fine
105/// (`type` → `TypeRequest`); only genuinely invalid characters or an empty base
106/// are rejected.
107///
108/// # Errors
109///
110/// Returns `Err(reason)` when the `PascalCase` base is not a legal identifier.
111pub fn validate_type_base(operation_id: &str) -> Result<(), String> {
112    let pascal = operation_id.to_pascal_case();
113    if !is_plain_ident(&pascal) {
114        return Err(format!(
115            "operationId `{operation_id}` produces the invalid Rust type-name base `{pascal}`; it must start with a letter or underscore and contain only letters, digits, and underscores"
116        ));
117    }
118    Ok(())
119}
120
121/// Validate an already-assembled type name (e.g. a synthesized query-param enum
122/// like `ListPetsStatusQuery`) and turn it into an [`Ident`].
123///
124/// # Errors
125///
126/// Returns `Err(reason)` when `name` is not a legal identifier.
127pub fn type_ident(name: &str, source: &str) -> Result<Ident, String> {
128    if !is_plain_ident(name) {
129        return Err(format!(
130            "synthesized type name `{name}` (from `{source}`) is not a valid Rust identifier"
131        ));
132    }
133    Ok(Ident::new(name, Span::call_site()))
134}
135
136/// Infallible keyword-safe identifier for contexts without a diagnostics
137/// channel (schema property field names).
138///
139/// Keywords become raw identifiers; the non-raw-able keywords get a trailing
140/// underscore (`self` → `self_`). Any other string is passed through unchanged,
141/// matching the prior behaviour of the per-module helper this replaces.
142#[must_use]
143pub fn keyword_safe_ident(name: &str) -> Ident {
144    if NON_RAW_KEYWORDS.contains(&name) {
145        return Ident::new(&format!("{name}_"), Span::call_site());
146    }
147    if RAW_SAFE_KEYWORDS.contains(&name) {
148        return Ident::new_raw(name, Span::call_site());
149    }
150    Ident::new(name, Span::call_site())
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156
157    #[test]
158    fn keyword_method_becomes_raw_ident() {
159        let id = method_ident("type").expect("keyword is raw-safe");
160        assert_eq!(id.to_string(), "r#type");
161        assert_eq!(method_ident("async").unwrap().to_string(), "r#async");
162    }
163
164    #[test]
165    fn hyphenated_and_clean_names_pass_through() {
166        assert_eq!(method_ident("list-pets").unwrap().to_string(), "list_pets");
167        assert_eq!(
168            method_ident("getPetById").unwrap().to_string(),
169            "get_pet_by_id"
170        );
171    }
172
173    #[test]
174    fn leading_digit_is_an_error() {
175        let err = method_ident("1pet").unwrap_err();
176        assert!(err.contains("operationId `1pet`"), "{err}");
177    }
178
179    #[test]
180    fn empty_after_conversion_is_an_error() {
181        assert!(method_ident("___").is_err());
182    }
183
184    #[test]
185    fn non_raw_keyword_is_an_error() {
186        let err = method_ident("self").unwrap_err();
187        assert!(err.contains("reserved Rust keyword"), "{err}");
188    }
189
190    #[test]
191    fn keyword_param_field_becomes_raw_ident() {
192        assert_eq!(field_ident("type").unwrap().to_string(), "r#type");
193    }
194
195    #[test]
196    fn type_base_keyword_is_fine_but_digit_is_not() {
197        // `type` -> `Type`, a valid base (a suffix is appended by callers).
198        assert!(validate_type_base("type").is_ok());
199        assert!(validate_type_base("1pet").is_err());
200    }
201
202    #[test]
203    fn infallible_helper_handles_non_raw_keyword() {
204        assert_eq!(keyword_safe_ident("self").to_string(), "self_");
205        assert_eq!(keyword_safe_ident("type").to_string(), "r#type");
206        assert_eq!(keyword_safe_ident("name").to_string(), "name");
207    }
208}