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_or(ch));
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_or(ch));
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().is_some_and(|c| c.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 Some(first_char) = chars.next() else {
295            return false;
296        };
297
298        // First character must be a letter or underscore
299        if !first_char.is_alphabetic() && first_char != '_' {
300            return false;
301        }
302
303        // Remaining characters must be alphanumeric or underscore
304        for ch in chars {
305            if !ch.is_alphanumeric() && ch != '_' {
306                return false;
307            }
308        }
309
310        // Check if it's a Rust keyword
311        !Self::is_rust_keyword(name)
312    }
313
314    /// Check if a string is a Rust keyword
315    pub fn is_rust_keyword(name: &str) -> bool {
316        matches!(
317            name,
318            "as" | "break"
319                | "const"
320                | "continue"
321                | "crate"
322                | "else"
323                | "enum"
324                | "extern"
325                | "false"
326                | "fn"
327                | "for"
328                | "if"
329                | "impl"
330                | "in"
331                | "let"
332                | "loop"
333                | "match"
334                | "mod"
335                | "move"
336                | "mut"
337                | "pub"
338                | "ref"
339                | "return"
340                | "self"
341                | "Self"
342                | "static"
343                | "struct"
344                | "super"
345                | "trait"
346                | "true"
347                | "type"
348                | "unsafe"
349                | "use"
350                | "where"
351                | "while"
352                | "async"
353                | "await"
354                | "dyn"
355                | "abstract"
356                | "become"
357                | "box"
358                | "do"
359                | "final"
360                | "macro"
361                | "override"
362                | "priv"
363                | "typeof"
364                | "unsized"
365                | "virtual"
366                | "yield"
367                | "try"
368        )
369    }
370
371    // =============================================================================
372    // HELPER FUNCTIONS
373    // =============================================================================
374
375    /// Handle Rust keywords by appending underscore
376    fn handle_rust_keywords(name: &str) -> String {
377        match name {
378            "type" => "type_".to_string(),
379            "match" => "match_".to_string(),
380            "loop" => "loop_".to_string(),
381            "move" => "move_".to_string(),
382            "ref" => "ref_".to_string(),
383            "mod" => "mod_".to_string(),
384            "use" => "use_".to_string(),
385            "self" => "self_".to_string(),
386            "super" => "super_".to_string(),
387            "crate" => "crate_".to_string(),
388            "async" => "async_".to_string(),
389            "await" => "await_".to_string(),
390            "fn" => "fn_".to_string(),
391            "let" => "let_".to_string(),
392            "const" => "const_".to_string(),
393            "static" => "static_".to_string(),
394            "struct" => "struct_".to_string(),
395            "enum" => "enum_".to_string(),
396            "impl" => "impl_".to_string(),
397            "trait" => "trait_".to_string(),
398            "for" => "for_".to_string(),
399            "if" => "if_".to_string(),
400            "else" => "else_".to_string(),
401            "while" => "while_".to_string(),
402            "return" => "return_".to_string(),
403            "where" => "where_".to_string(),
404            "abstract" => "abstract_".to_string(),
405            _ => name.to_string(),
406        }
407    }
408
409    /// Fix common FHIR acronyms to maintain proper casing
410    fn fix_acronyms(name: &str) -> String {
411        let mut result = name.to_string();
412
413        // Common FHIR acronyms that should be uppercase
414        let acronyms = [
415            ("Cqf", "CQF"),     // Clinical Quality Framework
416            ("Fhir", "FHIR"),   // Fast Healthcare Interoperability Resources
417            ("Hl7", "HL7"),     // Health Level 7
418            ("Http", "HTTP"),   // HyperText Transfer Protocol
419            ("Https", "HTTPS"), // HTTP Secure
420            ("Json", "JSON"),   // JavaScript Object Notation
421            ("Xml", "XML"),     // eXtensible Markup Language
422            ("Uuid", "UUID"),   // Universally Unique Identifier
423            ("Uri", "URI"),     // Uniform Resource Identifier
424            ("Url", "URL"),     // Uniform Resource Locator
425            ("Api", "API"),     // Application Programming Interface
426        ];
427
428        for (from, to) in &acronyms {
429            result = result.replace(from, to);
430        }
431
432        result
433    }
434}
435
436#[cfg(test)]
437mod tests {
438    use super::*;
439
440    #[test]
441    fn test_struct_name_generation() {
442        let structure = StructureDefinition {
443            resource_type: "StructureDefinition".to_string(),
444            id: "Patient".to_string(),
445            url: "http://hl7.org/fhir/StructureDefinition/Patient".to_string(),
446            name: "Patient".to_string(),
447            title: Some("Patient".to_string()),
448            status: "active".to_string(),
449            kind: "resource".to_string(),
450            is_abstract: false,
451            description: Some("A patient resource".to_string()),
452            purpose: None,
453            base_type: "DomainResource".to_string(),
454            base_definition: Some(
455                "http://hl7.org/fhir/StructureDefinition/DomainResource".to_string(),
456            ),
457            version: None,
458            differential: None,
459            snapshot: None,
460        };
461
462        assert_eq!(Naming::struct_name(&structure), "Patient");
463    }
464
465    #[test]
466    fn test_field_name_conversion() {
467        // Test basic field names
468        assert_eq!(Naming::field_name("active"), "active");
469        assert_eq!(Naming::field_name("name"), "name");
470
471        // Test PascalCase to snake_case conversion
472        assert_eq!(Naming::field_name("birthDate"), "birth_date");
473        assert_eq!(
474            Naming::field_name("multipleBirthBoolean"),
475            "multiple_birth_boolean"
476        );
477
478        // Test choice types with [x] suffix
479        assert_eq!(Naming::field_name("value[x]"), "value");
480        assert_eq!(Naming::field_name("deceased[x]"), "deceased");
481
482        // Test Rust keywords
483        assert_eq!(Naming::field_name("type"), "type_");
484        assert_eq!(Naming::field_name("use"), "use_");
485        assert_eq!(Naming::field_name("ref"), "ref_");
486        assert_eq!(Naming::field_name("for"), "for_");
487        assert_eq!(Naming::field_name("match"), "match_");
488
489        // Test base conflict resolution
490        assert_eq!(Naming::field_name("base"), "base_definition");
491    }
492
493    #[test]
494    fn test_filename_generation() {
495        let patient_structure = StructureDefinition {
496            resource_type: "StructureDefinition".to_string(),
497            id: "Patient".to_string(),
498            url: "http://hl7.org/fhir/StructureDefinition/Patient".to_string(),
499            name: "Patient".to_string(),
500            title: Some("Patient".to_string()),
501            status: "active".to_string(),
502            kind: "resource".to_string(),
503            is_abstract: false,
504            description: Some("A patient resource".to_string()),
505            purpose: None,
506            base_type: "DomainResource".to_string(),
507            base_definition: Some(
508                "http://hl7.org/fhir/StructureDefinition/DomainResource".to_string(),
509            ),
510            version: None,
511            differential: None,
512            snapshot: None,
513        };
514
515        assert_eq!(Naming::filename(&patient_structure), "patient.rs");
516
517        let structure_definition = StructureDefinition {
518            resource_type: "StructureDefinition".to_string(),
519            id: "StructureDefinition".to_string(),
520            url: "http://hl7.org/fhir/StructureDefinition/StructureDefinition".to_string(),
521            name: "StructureDefinition".to_string(),
522            title: Some("StructureDefinition".to_string()),
523            status: "active".to_string(),
524            kind: "resource".to_string(),
525            is_abstract: false,
526            description: Some("A structure definition resource".to_string()),
527            purpose: None,
528            base_type: "MetadataResource".to_string(),
529            base_definition: Some(
530                "http://hl7.org/fhir/StructureDefinition/MetadataResource".to_string(),
531            ),
532            version: None,
533            differential: None,
534            snapshot: None,
535        };
536
537        assert_eq!(
538            Naming::filename(&structure_definition),
539            "structure_definition.rs"
540        );
541    }
542
543    #[test]
544    fn test_snake_case_conversion() {
545        assert_eq!(Naming::to_snake_case("Patient"), "patient");
546        assert_eq!(
547            Naming::to_snake_case("StructureDefinition"),
548            "structure_definition"
549        );
550        assert_eq!(Naming::to_snake_case("HTTPRequest"), "http_request");
551        assert_eq!(Naming::to_snake_case("someField"), "some_field");
552    }
553
554    #[test]
555    fn test_pascal_case_conversion() {
556        assert_eq!(Naming::to_pascal_case("patient_name"), "PatientName");
557        assert_eq!(Naming::to_pascal_case("some_field"), "SomeField");
558        assert_eq!(Naming::to_pascal_case("http_request"), "HttpRequest");
559    }
560
561    #[test]
562    fn test_rust_identifier_conversion() {
563        // Test FHIR resource names that should preserve original case
564        assert_eq!(
565            Naming::to_rust_identifier("StructureDefinition"),
566            "StructureDefinition"
567        );
568        assert_eq!(Naming::to_rust_identifier("Patient"), "Patient");
569        assert_eq!(Naming::to_rust_identifier("Observation"), "Observation");
570        assert_eq!(Naming::to_rust_identifier("CodeSystem"), "CodeSystem");
571
572        // Test names with spaces
573        assert_eq!(
574            Naming::to_rust_identifier("Relative Date Criteria"),
575            "RelativeDateCriteria"
576        );
577        assert_eq!(Naming::to_rust_identifier("Care Plan"), "CarePlan");
578
579        // Test names with dashes and underscores
580        assert_eq!(Naming::to_rust_identifier("patient-name"), "PatientName");
581        assert_eq!(Naming::to_rust_identifier("patient_name"), "patient_name");
582
583        // Test mixed separators
584        assert_eq!(
585            Naming::to_rust_identifier("some-complex_name with.spaces"),
586            "SomeComplexNameWithSpaces"
587        );
588
589        // Test empty and edge cases
590        assert_eq!(Naming::to_rust_identifier(""), "_");
591        assert_eq!(Naming::to_rust_identifier("   "), "_");
592        assert_eq!(Naming::to_rust_identifier("a"), "a");
593    }
594
595    #[test]
596    fn test_type_suffix() {
597        assert_eq!(Naming::type_suffix("string"), "string");
598        assert_eq!(Naming::type_suffix("DateTime"), "date_time");
599        assert_eq!(Naming::type_suffix("CodeableConcept"), "codeable_concept");
600    }
601
602    #[test]
603    fn test_enum_filename() {
604        assert_eq!(Naming::enum_filename("PatientStatus"), "patient_status.rs");
605        assert_eq!(Naming::enum_filename("HTTPMethod"), "http_method.rs");
606    }
607
608    #[test]
609    fn test_trait_module_name() {
610        assert_eq!(Naming::trait_module_name("Patient"), "patient");
611        assert_eq!(
612            Naming::trait_module_name("Relative Date Criteria"),
613            "relative_date_criteria"
614        );
615        assert_eq!(Naming::trait_module_name("patient-name"), "patient_name");
616    }
617}