const RUST_KEYWORDS: &[&str] = &[
"as", "async", "await", "box", "break", "const", "continue", "crate", "do", "dyn", "else",
"enum", "extern", "false", "fn", "for", "gen", "if", "impl", "in", "let", "loop", "macro",
"match", "mod", "move", "mut", "override", "priv", "pub", "ref", "return", "self", "static",
"struct", "super", "trait", "true", "try", "type", "typeof", "union", "unsafe", "unsized",
"use", "virtual", "where", "while", "yield",
];
#[must_use]
pub fn is_rust_keyword(name: &str) -> bool {
RUST_KEYWORDS.contains(&name)
}
#[must_use]
pub fn words(name: &str) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for chunk in name.split(|c: char| !c.is_ascii_alphanumeric()) {
if chunk.is_empty() {
continue;
}
let chunk = chunk.trim_start_matches('_');
if chunk.is_empty() {
continue;
}
push_camel_words(chunk, &mut out);
}
out
}
fn push_camel_words(chunk: &str, out: &mut Vec<String>) {
let mut current = String::new();
let mut prev = None;
let mut chars = chunk.chars().peekable();
while let Some(cur) = chars.next() {
if let Some(prev_char) = prev {
let boundary = (prev_char.is_ascii_lowercase() && cur.is_ascii_uppercase())
|| (prev_char.is_ascii_uppercase()
&& cur.is_ascii_uppercase()
&& chars.peek().is_some_and(char::is_ascii_lowercase));
if boundary && !current.is_empty() {
out.push(std::mem::take(&mut current).to_ascii_lowercase());
}
}
current.push(cur);
prev = Some(cur);
}
if !current.is_empty() {
out.push(current.to_ascii_lowercase());
}
}
#[must_use]
pub fn field_ident(name: &str) -> String {
finish(words(name).join("_"), true)
}
#[must_use]
pub fn variant_ident(name: &str) -> String {
let joined: String = words(name)
.into_iter()
.map(|w| {
let mut chars = w.chars();
match chars.next() {
Some(first) => first.to_ascii_uppercase().to_string() + chars.as_str(),
None => String::new(),
}
})
.collect();
finish(joined, false)
}
#[must_use]
pub fn module_ident(revision: &str) -> String {
let joined = words(revision).join("_");
if joined.is_empty() {
return "v".to_owned();
}
format!("v{joined}")
}
fn finish(joined: String, allow_keyword_suffix: bool) -> String {
if joined.is_empty() {
return "_".to_owned();
}
if joined.starts_with(|c: char| c.is_ascii_digit()) {
return format!("_{joined}");
}
if allow_keyword_suffix && is_rust_keyword(&joined) {
return format!("{joined}_");
}
joined
}
#[cfg(test)]
mod tests {
use super::{field_ident, is_rust_keyword, module_ident, variant_ident, words};
#[test]
fn splits_every_casing_upstream_uses() {
assert_eq!(words("inputSchema"), ["input", "schema"]);
assert_eq!(words("JSONRPCRequest"), ["jsonrpc", "request"]);
assert_eq!(words("isError"), ["is", "error"]);
assert_eq!(words("nextCursor"), ["next", "cursor"]);
assert_eq!(words("date-time"), ["date", "time"]);
assert_eq!(words("input_required"), ["input", "required"]);
assert_eq!(words("allServers"), ["all", "servers"]);
assert_eq!(
words("io.modelcontextprotocol/clientInfo"),
["io", "modelcontextprotocol", "client", "info"]
);
assert_eq!(words("_meta"), ["meta"]);
assert_eq!(words("jsonrpc"), ["jsonrpc"]);
}
#[test]
fn an_all_capital_name_stays_one_word() {
assert_eq!(words("MCP"), ["mcp"]);
assert_eq!(words("URI"), ["uri"]);
}
#[test]
fn empty_and_symbol_only_names_are_still_identifiers() {
assert_eq!(field_ident(""), "_");
assert_eq!(field_ident("---"), "_");
assert_eq!(variant_ident(""), "_");
}
#[test]
fn a_leading_digit_is_escaped() {
assert_eq!(field_ident("2fa"), "_2fa");
}
#[test]
fn keywords_get_a_trailing_underscore() {
assert!(is_rust_keyword("type"));
assert!(is_rust_keyword("ref"));
assert!(!is_rust_keyword("tools"));
assert_eq!(field_ident("type"), "type_");
assert_eq!(field_ident("ref"), "ref_");
assert_eq!(field_ident("enum"), "enum_");
assert_eq!(field_ident("tool"), "tool");
}
#[test]
fn revisions_map_to_legal_modules() {
assert_eq!(module_ident("2024-11-05"), "v2024_11_05");
assert_eq!(module_ident("2026-07-28"), "v2026_07_28");
assert_eq!(module_ident("draft"), "v_draft");
}
#[test]
fn the_mcp_property_set_converts_without_collisions() {
let names = [
"_meta",
"cacheScope",
"clientInfo",
"costPriority",
"createdAt",
"destructiveHint",
"enum",
"enumNames",
"idempotentHint",
"includeContext",
"inputRequests",
"inputResponses",
"inputSchema",
"isError",
"io.modelcontextprotocol/clientCapabilities",
"io.modelcontextprotocol/clientInfo",
"io.modelcontextprotocol/logLevel",
"io.modelcontextprotocol/protocolVersion",
"io.modelcontextprotocol/serverInfo",
"io.modelcontextprotocol/subscriptionId",
"lastModified",
"maxItems",
"maxLength",
"maxTokens",
"mimeType",
"nextCursor",
"oneOf",
"outputSchema",
"pollInterval",
"progressToken",
"promptsListChanged",
"readOnlyHint",
"ref",
"requestId",
"resourceSubscriptions",
"resourcesListChanged",
"rootUri",
"stopSequences",
"structuredContent",
"supportedVersions",
"systemPrompt",
"taskSupport",
"title",
"toolChoice",
"toolsListChanged",
"ttlMs",
"type",
"uriTemplate",
"websiteUrl",
];
let mut seen = std::collections::BTreeMap::new();
for name in names {
let ident = field_ident(name);
if let Some(previous) = seen.insert(ident.clone(), name) {
panic!("{previous} and {name} both map to {ident}");
}
}
}
}