use ::convert_case::{Case, Casing};
const RUST_KEYWORDS: [&str; 36] = [
"abstract", "as", "async", "await", "become", "box", "break", "const", "continue", "crate",
"do", "dyn", "else", "enum", "extern", "final", "fn", "for", "if", "impl", "in", "let", "loop",
"match", "mod", "move", "mut", "override", "priv", "pub", "ref", "return", "static", "type",
"typeof", "use",
];
const NON_RAW_KEYWORDS: [&str; 3] = ["crate", "self", "super"];
#[must_use]
pub fn pascal(s: &str) -> String {
s.to_case(Case::Pascal)
}
#[must_use]
pub fn snake(s: &str) -> String {
s.to_case(Case::Snake)
}
#[must_use]
pub fn field_ident(name: &str) -> String {
let snake = snake(name);
if NON_RAW_KEYWORDS.contains(&snake.as_str()) {
format!("{snake}_")
} else if RUST_KEYWORDS.contains(&snake.as_str()) {
format!("r#{snake}")
} else {
snake
}
}
#[must_use]
pub fn serde_camel(ident: &str) -> String {
let bare = ident.strip_prefix("r#").unwrap_or(ident);
let mut out = String::new();
let mut capitalize = false;
for c in bare.chars() {
if c == '_' {
capitalize = true;
} else if capitalize {
out.extend(c.to_uppercase());
capitalize = false;
} else {
out.push(c);
}
}
out
}
#[must_use]
pub fn needs_explicit_rename(ident: &str, fhir_name: &str) -> bool {
serde_camel(ident) != fhir_name
}
#[must_use]
pub fn struct_name(path: &str) -> String {
path.split('.').map(pascal).collect()
}
#[must_use]
pub fn module_name(type_name: &str) -> String {
snake(type_name)
}
#[must_use]
pub fn choice_key(base: &str, type_code: &str) -> String {
let mut chars = type_code.chars();
let first = chars.next().map(|c| c.to_ascii_uppercase());
format!("{base}{}{}", first.map(String::from).unwrap_or_default(), chars.as_str())
}
#[must_use]
pub fn enum_name(s: &str) -> String {
sanitize_variant(s)
}
#[must_use]
pub fn sanitize_variant(code: &str) -> String {
let cleaned: String = code
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { ' ' })
.collect();
let mut name = pascal(cleaned.trim());
if name.is_empty() {
name = "Unnamed".to_string();
}
if name.starts_with(|c: char| c.is_ascii_digit()) {
name.insert(0, 'N');
}
if name == "Self" {
name = "SelfCode".to_string();
}
name
}
#[must_use]
pub fn dedupe(names: &[String]) -> Vec<String> {
let mut seen: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
names
.iter()
.map(|name| {
let count = seen.entry(name.clone()).or_insert(0);
*count += 1;
if *count == 1 {
name.clone()
} else {
format!("{name}{count}")
}
})
.collect()
}
#[must_use]
pub fn doc_comment(text: &str, indent: &str) -> String {
let text = collapse_whitespace(text);
if text.is_empty() {
return String::new();
}
wrap(&text, 79 - indent.len() - 4).into_iter().fold(String::new(), |mut out, line| {
out.push_str(indent);
out.push_str("/// ");
out.push_str(&line);
out.push('\n');
out
})
}
#[must_use]
pub fn module_doc_comment(text: &str) -> String {
let text = collapse_whitespace(text);
if text.is_empty() {
return String::new();
}
wrap(&text, 75).into_iter().fold(String::new(), |mut out, line| {
out.push_str("//! ");
out.push_str(&line);
out.push('\n');
out
})
}
fn collapse_whitespace(text: &str) -> String {
text.split_whitespace().collect::<Vec<_>>().join(" ")
}
fn wrap(text: &str, width: usize) -> Vec<String> {
let mut lines = Vec::new();
let mut current = String::new();
for word in text.split(' ') {
if current.is_empty() {
current = word.to_string();
} else if current.chars().count() + 1 + word.chars().count() <= width {
current.push(' ');
current.push_str(word);
} else {
lines.push(std::mem::take(&mut current));
current = word.to_string();
}
}
if !current.is_empty() {
lines.push(current);
}
for line in &mut lines {
escape_block_marker(line);
}
lines
}
fn escape_block_marker(line: &mut String) {
let starts_a_block = match line.chars().next() {
Some('-' | '+' | '*' | '>' | '#' | '=') => true,
Some(c) if c.is_ascii_digit() => {
let rest = line.trim_start_matches(|c: char| c.is_ascii_digit());
rest.starts_with(". ") || rest.starts_with(") ")
}
_ => false,
};
if starts_a_block {
line.insert(0, '\\');
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn identifiers() {
assert_eq!(field_ident("referenceRange"), "reference_range");
assert_eq!(field_ident("type"), "r#type");
assert_eq!(field_ident("use"), "r#use");
assert_eq!(field_ident("abstract"), "r#abstract");
assert_eq!(field_ident("name"), "name");
}
#[test]
fn serde_camel_matches_serdes_own_rule() {
assert_eq!(serde_camel("reference_range"), "referenceRange");
assert_eq!(serde_camel("r#type"), "type");
assert_eq!(serde_camel("name"), "name");
assert_eq!(serde_camel("truth_tp"), "truthTp");
}
#[test]
fn only_unrecoverable_names_need_a_rename() {
assert!(!needs_explicit_rename("reference_range", "referenceRange"));
assert!(!needs_explicit_rename("r#type", "type"));
assert!(needs_explicit_rename("truth_tp", "truthTP"));
assert!(needs_explicit_rename("request_url", "requestURL"));
}
#[test]
fn struct_names_join_segments() {
assert_eq!(struct_name("Patient.contact"), "PatientContact");
assert_eq!(struct_name("Claim.item.detail"), "ClaimItemDetail");
assert_eq!(struct_name("Timing.repeat"), "TimingRepeat");
assert_eq!(struct_name("Observation"), "Observation");
}
#[test]
fn module_names() {
assert_eq!(module_name("CodeableConcept"), "codeable_concept");
assert_eq!(module_name("Base64Binary"), "base_64_binary");
}
#[test]
fn choice_keys_capitalize_the_type_code() {
assert_eq!(choice_key("value", "dateTime"), "valueDateTime");
assert_eq!(choice_key("value", "CodeableConcept"), "valueCodeableConcept");
assert_eq!(choice_key("value", "base64Binary"), "valueBase64Binary");
assert_eq!(choice_key("onset", "Age"), "onsetAge");
}
#[test]
fn variants_are_valid_identifiers() {
assert_eq!(sanitize_variant("entered-in-error"), "EnteredInError");
assert_eq!(sanitize_variant("final"), "Final");
assert_eq!(sanitize_variant("1.0"), "N10");
assert_eq!(sanitize_variant("<"), "Unnamed");
assert_eq!(sanitize_variant("self"), "SelfCode");
assert_eq!(sanitize_variant("_"), "Unnamed");
}
#[test]
fn collisions_get_numbered() {
let names = vec!["A".to_string(), "B".to_string(), "A".to_string(), "A".to_string()];
assert_eq!(dedupe(&names), ["A", "B", "A2", "A3"]);
}
#[test]
fn docs_wrap_and_collapse() {
let doc = doc_comment("one\n two three", " ");
assert_eq!(doc, " /// one two three\n");
let long = doc_comment(&"word ".repeat(40), "");
assert!(long.lines().count() > 1);
assert!(long.lines().all(|l| l.starts_with("/// ")));
}
#[test]
fn wrapped_lines_never_start_a_markdown_block() {
let doc = doc_comment(
"A list of the base types defined by this version of the FHIR specification \
- types that are defined, but for which only specializations actually are created.",
"",
);
assert!(doc.contains("/// \\- types"), "{doc}");
assert!(!doc.contains("/// - types"), "{doc}");
}
#[test]
fn ordinary_prose_is_left_alone() {
assert_eq!(doc_comment("plain text", ""), "/// plain text\n");
}
#[test]
fn empty_docs_are_omitted() {
assert_eq!(doc_comment("", " "), "");
assert_eq!(module_doc_comment(" "), "");
}
}