Skip to main content

rh_codegen/
naming.rs

1//! Centralized naming utilities for FHIR code generation
2//!
3//! This module provides a consistent, clean interface for converting FHIR names
4//! to valid Rust identifiers, including struct names, field names, filenames,
5//! and module names. All naming logic is centralized here to avoid duplication
6//! and ensure consistency across the codebase.
7//!
8//! ## Key Features
9//!
10//! - **Struct Naming**: Converts FHIR StructureDefinition names to valid Rust struct names
11//! - **Field Naming**: Converts FHIR field names to snake_case with keyword handling
12//! - **File Naming**: Generates appropriate filenames for generated Rust code
13//! - **Case Conversions**: Handles PascalCase, snake_case, and identifier validation
14//! - **Keyword Handling**: Manages Rust keyword conflicts by appending underscores
15//! - **FHIR Conventions**: Preserves FHIR naming conventions where appropriate
16//!
17//! ## Usage
18//!
19//! ```rust
20//! use rh_codegen::naming::Naming;
21//! use rh_codegen::fhir_types::StructureDefinition;
22//!
23//! // Create a sample structure definition
24//! let structure_def = StructureDefinition {
25//!     resource_type: "StructureDefinition".to_string(),
26//!     name: "Patient".to_string(),
27//!     id: "Patient".to_string(),
28//!     url: "http://hl7.org/fhir/StructureDefinition/Patient".to_string(),
29//!     version: None,
30//!     title: None,
31//!     status: "active".to_string(),
32//!     description: None,
33//!     purpose: None,
34//!     kind: "resource".to_string(),
35//!     is_abstract: false,
36//!     base_type: "DomainResource".to_string(),
37//!     base_definition: Some("http://hl7.org/fhir/StructureDefinition/DomainResource".to_string()),
38//!     differential: None,
39//!     snapshot: None,
40//! };
41//!
42//! // Generate struct name
43//! let struct_name = Naming::struct_name(&structure_def);
44//!
45//! // Convert field name
46//! let field_name = Naming::field_name("birthDate"); // -> "birth_date"
47//!
48//! // Generate filename
49//! let filename = Naming::filename(&structure_def); // -> "patient.rs"
50//!
51//! // Case conversions
52//! let snake_case = Naming::to_snake_case("PatientName"); // -> "patient_name"
53//! let pascal_case = Naming::to_pascal_case("patient_name"); // -> "PatientName"
54//! ```
55//!
56//! ## Migration from Legacy Naming
57//!
58//! This module replaces the previously scattered naming functions from:
59//! - `GeneratorUtils::generate_struct_name` → `Naming::struct_name`
60//! - `GeneratorUtils::to_rust_field_name` → `Naming::field_name`
61//! - `GeneratorUtils::to_snake_case` → `Naming::to_snake_case`
62//! - `GeneratorUtils::to_filename` → `Naming::filename`
63//! - `NameGenerator::*` → `Naming::*`
64//! - `FieldGenerator::to_rust_field_name` → `Naming::field_name`
65
66use crate::fhir_types::StructureDefinition;
67
68/// Central naming utility for converting FHIR names to Rust identifiers
69pub struct Naming;
70
71impl Naming {
72    // =============================================================================
73    // STRUCT NAMING
74    // =============================================================================
75
76    /// Generate a proper Rust struct name from StructureDefinition
77    pub fn struct_name(structure_def: &StructureDefinition) -> String {
78        let raw_name = if structure_def.name == "alternate" {
79            // Special case for "alternate" name - use ID
80            Self::to_rust_identifier(&structure_def.id)
81        } else if structure_def.name.is_empty() {
82            // No name provided - use ID
83            Self::to_rust_identifier(&structure_def.id)
84        } else if structure_def.name != structure_def.id && !structure_def.id.is_empty() {
85            // Name and ID differ - prefer ID for uniqueness, especially for extensions
86            // This handles cases like cqf-library where name="library" but id="cqf-library"
87            Self::to_rust_identifier(&structure_def.id)
88        } else {
89            // Use name when it matches ID or ID is empty
90            Self::to_rust_identifier(&structure_def.name)
91        };
92
93        // FHIR convention is to have capitalized names for non-primitive types
94        if structure_def.kind != "primitive-type" {
95            Self::capitalize_first(&raw_name)
96        } else {
97            raw_name
98        }
99    }
100
101    // =============================================================================
102    // FIELD NAMING
103    // =============================================================================
104
105    /// Convert a FHIR field name to a valid Rust field name
106    pub fn field_name(name: &str) -> String {
107        // Handle FHIR choice types (fields ending with [x])
108        let clean_name = if name.ends_with("[x]") {
109            name.strip_suffix("[x]").unwrap_or(name)
110        } else {
111            name
112        };
113
114        // Handle field name conflicts with inherited base field
115        let conflict_resolved_name = if clean_name == "base" {
116            // Rename FHIR 'base' elements to avoid conflict with the inherited base field
117            "base_definition"
118        } else {
119            clean_name
120        };
121
122        // Convert to snake_case and handle Rust keywords
123        let snake_case = conflict_resolved_name
124            .chars()
125            .enumerate()
126            .map(|(i, c)| {
127                if c.is_uppercase() && i > 0 {
128                    format!("_{}", c.to_lowercase())
129                } else {
130                    c.to_lowercase().to_string()
131                }
132            })
133            .collect::<String>();
134
135        // Handle Rust keywords by appending underscore
136        Self::handle_rust_keywords(&snake_case)
137    }
138
139    /// Convert FHIR type code to snake_case for field suffix in choice types
140    pub fn type_suffix(type_code: &str) -> String {
141        type_code
142            .chars()
143            .enumerate()
144            .map(|(i, c)| {
145                if c.is_uppercase() && i > 0 {
146                    format!("_{}", c.to_lowercase())
147                } else {
148                    c.to_lowercase().to_string()
149                }
150            })
151            .collect()
152    }
153
154    // =============================================================================
155    // FILE NAMING
156    // =============================================================================
157
158    /// Convert a StructureDefinition to a filename using snake_case
159    pub fn filename(structure_def: &StructureDefinition) -> String {
160        let struct_name = Self::struct_name(structure_def);
161        let snake_case_name = Self::to_snake_case(&struct_name);
162        format!("{snake_case_name}.rs")
163    }
164
165    /// Convert an enum name to a filename using snake_case
166    pub fn enum_filename(enum_name: &str) -> String {
167        let snake_case_name = Self::to_snake_case(enum_name);
168        format!("{snake_case_name}.rs")
169    }
170
171    // =============================================================================
172    // MODULE NAMING
173    // =============================================================================
174
175    /// Convert an enum name to a module name using snake_case
176    pub fn module_name(enum_name: &str) -> String {
177        Self::to_snake_case(enum_name)
178    }
179
180    /// Convert a trait name to a module name using snake_case
181    pub fn trait_module_name(name: &str) -> String {
182        // First handle spaces, dashes, dots, and other separators
183        let cleaned = name
184            .replace([' ', '-', '.'], "_")
185            .replace(['(', ')', '[', ']'], "")
186            .replace(['/', '\\', ':'], "_");
187
188        // Then apply snake_case conversion for CamelCase
189        Self::to_snake_case(&cleaned)
190            .chars()
191            .filter(|c| c.is_alphanumeric() || *c == '_')
192            .collect()
193    }
194
195    // =============================================================================
196    // CASE CONVERSIONS
197    // =============================================================================
198
199    /// Convert a PascalCase type name to snake_case
200    pub fn to_snake_case(name: &str) -> String {
201        let mut result = String::new();
202        let chars: Vec<char> = name.chars().collect();
203
204        for (i, &ch) in chars.iter().enumerate() {
205            if ch.is_uppercase() && i > 0 {
206                // Check if this is part of an acronym or start of a new word
207                let is_acronym_continuation = i > 0 && chars[i - 1].is_uppercase();
208                let is_followed_by_lowercase = i + 1 < chars.len() && chars[i + 1].is_lowercase();
209
210                // Add underscore if:
211                // 1. Previous char was lowercase (start of new word like "someWord")
212                // 2. This is an acronym followed by lowercase (like "HTTPRequest" -> "http_request")
213                if (i > 0 && chars[i - 1].is_lowercase())
214                    || (is_acronym_continuation && is_followed_by_lowercase)
215                {
216                    result.push('_');
217                }
218            }
219
220            result.push(ch.to_lowercase().next().unwrap());
221        }
222
223        result
224    }
225
226    /// Convert a snake_case string to PascalCase
227    pub fn to_pascal_case(s: &str) -> String {
228        s.split('_')
229            .map(|word| {
230                let mut chars = word.chars();
231                match chars.next() {
232                    None => String::new(),
233                    Some(first) => {
234                        first.to_uppercase().collect::<String>() + &chars.as_str().to_lowercase()
235                    }
236                }
237            })
238            .collect()
239    }
240
241    /// Capitalize the first letter of a string
242    pub fn capitalize_first(s: &str) -> String {
243        if s.is_empty() {
244            return s.to_string();
245        }
246        s[0..1].to_uppercase() + &s[1..]
247    }
248
249    // =============================================================================
250    // RUST IDENTIFIER VALIDATION AND CONVERSION
251    // =============================================================================
252
253    /// Convert a FHIR name to a valid Rust identifier while preserving the original as much as possible
254    pub fn to_rust_identifier(name: &str) -> String {
255        // For names that are already valid Rust identifiers, use them as-is
256        if Self::is_valid_rust_identifier(name) {
257            return name.to_string();
258        }
259
260        // For names with spaces, dashes, or other characters, convert to PascalCase
261        let mut result = String::new();
262        let mut capitalize_next = true;
263
264        for ch in name.chars() {
265            if ch.is_alphanumeric() {
266                if capitalize_next {
267                    result.push(ch.to_uppercase().next().unwrap());
268                    capitalize_next = false;
269                } else {
270                    result.push(ch);
271                }
272            } else {
273                // Skip non-alphanumeric characters and capitalize the next letter
274                capitalize_next = true;
275            }
276        }
277
278        // Ensure it starts with a letter or underscore (Rust requirement)
279        if result.is_empty() || result.chars().next().unwrap().is_numeric() {
280            result = format!("_{result}");
281        }
282
283        // Handle common FHIR acronyms that should remain uppercase
284        Self::fix_acronyms(&result)
285    }
286
287    /// Check if a string is a valid Rust identifier
288    pub fn is_valid_rust_identifier(name: &str) -> bool {
289        if name.is_empty() {
290            return false;
291        }
292
293        let mut chars = name.chars();
294        let first_char = chars.next().unwrap();
295
296        // First character must be a letter or underscore
297        if !first_char.is_alphabetic() && first_char != '_' {
298            return false;
299        }
300
301        // Remaining characters must be alphanumeric or underscore
302        for ch in chars {
303            if !ch.is_alphanumeric() && ch != '_' {
304                return false;
305            }
306        }
307
308        // Check if it's a Rust keyword
309        !Self::is_rust_keyword(name)
310    }
311
312    /// Check if a string is a Rust keyword
313    pub fn is_rust_keyword(name: &str) -> bool {
314        matches!(
315            name,
316            "as" | "break"
317                | "const"
318                | "continue"
319                | "crate"
320                | "else"
321                | "enum"
322                | "extern"
323                | "false"
324                | "fn"
325                | "for"
326                | "if"
327                | "impl"
328                | "in"
329                | "let"
330                | "loop"
331                | "match"
332                | "mod"
333                | "move"
334                | "mut"
335                | "pub"
336                | "ref"
337                | "return"
338                | "self"
339                | "Self"
340                | "static"
341                | "struct"
342                | "super"
343                | "trait"
344                | "true"
345                | "type"
346                | "unsafe"
347                | "use"
348                | "where"
349                | "while"
350                | "async"
351                | "await"
352                | "dyn"
353                | "abstract"
354                | "become"
355                | "box"
356                | "do"
357                | "final"
358                | "macro"
359                | "override"
360                | "priv"
361                | "typeof"
362                | "unsized"
363                | "virtual"
364                | "yield"
365                | "try"
366        )
367    }
368
369    // =============================================================================
370    // HELPER FUNCTIONS
371    // =============================================================================
372
373    /// Handle Rust keywords by appending underscore
374    fn handle_rust_keywords(name: &str) -> String {
375        match name {
376            "type" => "type_".to_string(),
377            "match" => "match_".to_string(),
378            "loop" => "loop_".to_string(),
379            "move" => "move_".to_string(),
380            "ref" => "ref_".to_string(),
381            "mod" => "mod_".to_string(),
382            "use" => "use_".to_string(),
383            "self" => "self_".to_string(),
384            "super" => "super_".to_string(),
385            "crate" => "crate_".to_string(),
386            "async" => "async_".to_string(),
387            "await" => "await_".to_string(),
388            "fn" => "fn_".to_string(),
389            "let" => "let_".to_string(),
390            "const" => "const_".to_string(),
391            "static" => "static_".to_string(),
392            "struct" => "struct_".to_string(),
393            "enum" => "enum_".to_string(),
394            "impl" => "impl_".to_string(),
395            "trait" => "trait_".to_string(),
396            "for" => "for_".to_string(),
397            "if" => "if_".to_string(),
398            "else" => "else_".to_string(),
399            "while" => "while_".to_string(),
400            "return" => "return_".to_string(),
401            "where" => "where_".to_string(),
402            "abstract" => "abstract_".to_string(),
403            _ => name.to_string(),
404        }
405    }
406
407    /// Fix common FHIR acronyms to maintain proper casing
408    fn fix_acronyms(name: &str) -> String {
409        let mut result = name.to_string();
410
411        // Common FHIR acronyms that should be uppercase
412        let acronyms = [
413            ("Cqf", "CQF"),     // Clinical Quality Framework
414            ("Fhir", "FHIR"),   // Fast Healthcare Interoperability Resources
415            ("Hl7", "HL7"),     // Health Level 7
416            ("Http", "HTTP"),   // HyperText Transfer Protocol
417            ("Https", "HTTPS"), // HTTP Secure
418            ("Json", "JSON"),   // JavaScript Object Notation
419            ("Xml", "XML"),     // eXtensible Markup Language
420            ("Uuid", "UUID"),   // Universally Unique Identifier
421            ("Uri", "URI"),     // Uniform Resource Identifier
422            ("Url", "URL"),     // Uniform Resource Locator
423            ("Api", "API"),     // Application Programming Interface
424        ];
425
426        for (from, to) in &acronyms {
427            result = result.replace(from, to);
428        }
429
430        result
431    }
432}
433
434#[cfg(test)]
435mod tests {
436    use super::*;
437
438    #[test]
439    fn test_struct_name_generation() {
440        let structure = StructureDefinition {
441            resource_type: "StructureDefinition".to_string(),
442            id: "Patient".to_string(),
443            url: "http://hl7.org/fhir/StructureDefinition/Patient".to_string(),
444            name: "Patient".to_string(),
445            title: Some("Patient".to_string()),
446            status: "active".to_string(),
447            kind: "resource".to_string(),
448            is_abstract: false,
449            description: Some("A patient resource".to_string()),
450            purpose: None,
451            base_type: "DomainResource".to_string(),
452            base_definition: Some(
453                "http://hl7.org/fhir/StructureDefinition/DomainResource".to_string(),
454            ),
455            version: None,
456            differential: None,
457            snapshot: None,
458        };
459
460        assert_eq!(Naming::struct_name(&structure), "Patient");
461    }
462
463    #[test]
464    fn test_field_name_conversion() {
465        // Test basic field names
466        assert_eq!(Naming::field_name("active"), "active");
467        assert_eq!(Naming::field_name("name"), "name");
468
469        // Test PascalCase to snake_case conversion
470        assert_eq!(Naming::field_name("birthDate"), "birth_date");
471        assert_eq!(
472            Naming::field_name("multipleBirthBoolean"),
473            "multiple_birth_boolean"
474        );
475
476        // Test choice types with [x] suffix
477        assert_eq!(Naming::field_name("value[x]"), "value");
478        assert_eq!(Naming::field_name("deceased[x]"), "deceased");
479
480        // Test Rust keywords
481        assert_eq!(Naming::field_name("type"), "type_");
482        assert_eq!(Naming::field_name("use"), "use_");
483        assert_eq!(Naming::field_name("ref"), "ref_");
484        assert_eq!(Naming::field_name("for"), "for_");
485        assert_eq!(Naming::field_name("match"), "match_");
486
487        // Test base conflict resolution
488        assert_eq!(Naming::field_name("base"), "base_definition");
489    }
490
491    #[test]
492    fn test_filename_generation() {
493        let patient_structure = StructureDefinition {
494            resource_type: "StructureDefinition".to_string(),
495            id: "Patient".to_string(),
496            url: "http://hl7.org/fhir/StructureDefinition/Patient".to_string(),
497            name: "Patient".to_string(),
498            title: Some("Patient".to_string()),
499            status: "active".to_string(),
500            kind: "resource".to_string(),
501            is_abstract: false,
502            description: Some("A patient resource".to_string()),
503            purpose: None,
504            base_type: "DomainResource".to_string(),
505            base_definition: Some(
506                "http://hl7.org/fhir/StructureDefinition/DomainResource".to_string(),
507            ),
508            version: None,
509            differential: None,
510            snapshot: None,
511        };
512
513        assert_eq!(Naming::filename(&patient_structure), "patient.rs");
514
515        let structure_definition = StructureDefinition {
516            resource_type: "StructureDefinition".to_string(),
517            id: "StructureDefinition".to_string(),
518            url: "http://hl7.org/fhir/StructureDefinition/StructureDefinition".to_string(),
519            name: "StructureDefinition".to_string(),
520            title: Some("StructureDefinition".to_string()),
521            status: "active".to_string(),
522            kind: "resource".to_string(),
523            is_abstract: false,
524            description: Some("A structure definition resource".to_string()),
525            purpose: None,
526            base_type: "MetadataResource".to_string(),
527            base_definition: Some(
528                "http://hl7.org/fhir/StructureDefinition/MetadataResource".to_string(),
529            ),
530            version: None,
531            differential: None,
532            snapshot: None,
533        };
534
535        assert_eq!(
536            Naming::filename(&structure_definition),
537            "structure_definition.rs"
538        );
539    }
540
541    #[test]
542    fn test_snake_case_conversion() {
543        assert_eq!(Naming::to_snake_case("Patient"), "patient");
544        assert_eq!(
545            Naming::to_snake_case("StructureDefinition"),
546            "structure_definition"
547        );
548        assert_eq!(Naming::to_snake_case("HTTPRequest"), "http_request");
549        assert_eq!(Naming::to_snake_case("someField"), "some_field");
550    }
551
552    #[test]
553    fn test_pascal_case_conversion() {
554        assert_eq!(Naming::to_pascal_case("patient_name"), "PatientName");
555        assert_eq!(Naming::to_pascal_case("some_field"), "SomeField");
556        assert_eq!(Naming::to_pascal_case("http_request"), "HttpRequest");
557    }
558
559    #[test]
560    fn test_rust_identifier_conversion() {
561        // Test FHIR resource names that should preserve original case
562        assert_eq!(
563            Naming::to_rust_identifier("StructureDefinition"),
564            "StructureDefinition"
565        );
566        assert_eq!(Naming::to_rust_identifier("Patient"), "Patient");
567        assert_eq!(Naming::to_rust_identifier("Observation"), "Observation");
568        assert_eq!(Naming::to_rust_identifier("CodeSystem"), "CodeSystem");
569
570        // Test names with spaces
571        assert_eq!(
572            Naming::to_rust_identifier("Relative Date Criteria"),
573            "RelativeDateCriteria"
574        );
575        assert_eq!(Naming::to_rust_identifier("Care Plan"), "CarePlan");
576
577        // Test names with dashes and underscores
578        assert_eq!(Naming::to_rust_identifier("patient-name"), "PatientName");
579        assert_eq!(Naming::to_rust_identifier("patient_name"), "patient_name");
580
581        // Test mixed separators
582        assert_eq!(
583            Naming::to_rust_identifier("some-complex_name with.spaces"),
584            "SomeComplexNameWithSpaces"
585        );
586
587        // Test empty and edge cases
588        assert_eq!(Naming::to_rust_identifier(""), "_");
589        assert_eq!(Naming::to_rust_identifier("   "), "_");
590        assert_eq!(Naming::to_rust_identifier("a"), "a");
591    }
592
593    #[test]
594    fn test_type_suffix() {
595        assert_eq!(Naming::type_suffix("string"), "string");
596        assert_eq!(Naming::type_suffix("DateTime"), "date_time");
597        assert_eq!(Naming::type_suffix("CodeableConcept"), "codeable_concept");
598    }
599
600    #[test]
601    fn test_enum_filename() {
602        assert_eq!(Naming::enum_filename("PatientStatus"), "patient_status.rs");
603        assert_eq!(Naming::enum_filename("HTTPMethod"), "http_method.rs");
604    }
605
606    #[test]
607    fn test_trait_module_name() {
608        assert_eq!(Naming::trait_module_name("Patient"), "patient");
609        assert_eq!(
610            Naming::trait_module_name("Relative Date Criteria"),
611            "relative_date_criteria"
612        );
613        assert_eq!(Naming::trait_module_name("patient-name"), "patient_name");
614    }
615}