use std::borrow::Cow;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "lowercase"))]
#[non_exhaustive]
pub enum Spelling {
#[default]
Snake,
Camel,
}
impl Spelling {
pub fn as_str(self) -> &'static str {
match self {
Spelling::Snake => "snake",
Spelling::Camel => "camel",
}
}
pub fn parse(value: &str) -> Option<Self> {
if value.eq_ignore_ascii_case("snake") {
Some(Spelling::Snake)
} else if value.eq_ignore_ascii_case("camel") {
Some(Spelling::Camel)
} else {
None
}
}
}
impl std::fmt::Display for Spelling {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
fn camel_case(name: &str) -> String {
let mut out = String::with_capacity(name.len());
let mut upper = false;
for c in name.chars() {
if c == '_' {
upper = true;
} else if upper {
out.push(c.to_ascii_uppercase());
upper = false;
} else {
out.push(c);
}
}
out
}
#[cfg(feature = "script")]
fn registered_pairs() -> &'static std::collections::HashMap<String, String> {
static PAIRS: std::sync::OnceLock<std::collections::HashMap<String, String>> =
std::sync::OnceLock::new();
PAIRS.get_or_init(|| {
let names: std::collections::HashSet<String> =
crate::script::registered_step_names().into_iter().collect();
names
.iter()
.filter(|name| is_snake_step_name(name))
.filter_map(|name| {
let camel = camel_case(name);
names.contains(&camel).then(|| (name.clone(), camel))
})
.collect()
})
}
fn is_snake_step_name(name: &str) -> bool {
name.starts_with(|c: char| c.is_ascii_lowercase())
&& name.contains('_')
&& !name.ends_with('_')
&& name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
}
pub(crate) fn camel_name(snake: &str) -> Cow<'_, str> {
if !is_snake_step_name(snake) {
return Cow::Borrowed(snake);
}
#[cfg(feature = "script")]
if let Some(camel) = registered_pairs().get(snake) {
return Cow::Owned(camel.clone());
}
Cow::Owned(camel_case(snake))
}
pub(crate) fn respell(text: &str, spelling: Spelling) -> Cow<'_, str> {
if spelling == Spelling::Snake || !text.contains('_') {
return Cow::Borrowed(text);
}
let mut out = String::with_capacity(text.len());
let mut in_string = false;
let mut escaped = false;
let mut chars = text.char_indices().peekable();
let mut prev: Option<char> = None;
while let Some((start, c)) = chars.next() {
if in_string {
out.push(c);
if escaped {
escaped = false;
} else if c == '\\' {
escaped = true;
} else if c == '"' {
in_string = false;
}
prev = Some(c);
continue;
}
if c == '"' {
in_string = true;
out.push(c);
prev = Some(c);
continue;
}
let starts_identifier = (c.is_ascii_alphabetic() || c == '_')
&& !prev.is_some_and(|p| p.is_ascii_alphanumeric() || p == '_');
if !starts_identifier {
out.push(c);
prev = Some(c);
continue;
}
let mut end = start + c.len_utf8();
while let Some(&(index, next)) = chars.peek() {
if next.is_ascii_alphanumeric() || next == '_' {
end = index + next.len_utf8();
chars.next();
} else {
break;
}
}
let identifier = &text[start..end];
let called = chars.peek().is_some_and(|&(_, next)| next == '(');
if called {
out.push_str(&camel_name(identifier));
} else {
out.push_str(identifier);
}
prev = identifier.chars().last();
}
Cow::Owned(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn respell_renames_calls_outside_strings_only() {
assert_eq!(
respell(
r#"v().has_label("has_label(").group_count().by(T.label).out_e()"#,
Spelling::Camel
),
r#"v().hasLabel("has_label(").groupCount().by(T.label).outE()"#
);
assert_eq!(
respell(r#"values("a\"b_c(").to_list()"#, Spelling::Camel),
r#"values("a\"b_c(").toList()"#
);
assert_eq!(
respell(
r#"glob_path("a", #{max_depth: 2}).math("_ + 1")"#,
Spelling::Camel
),
r#"globPath("a", #{max_depth: 2}).math("_ + 1")"#
);
}
#[test]
fn respell_is_idempotent_and_snake_is_identity() {
let once = respell("v().side_effect(__.drop()).lazy_barrier()", Spelling::Camel);
assert_eq!(once, "v().sideEffect(__.drop()).lazyBarrier()");
assert_eq!(respell(&once, Spelling::Camel), once);
assert_eq!(
respell("v().has_label(\"x\")", Spelling::Snake),
"v().has_label(\"x\")"
);
}
#[test]
fn spelling_parses_option_values() {
assert_eq!(Spelling::parse("camel"), Some(Spelling::Camel));
assert_eq!(Spelling::parse("Snake"), Some(Spelling::Snake));
assert_eq!(Spelling::parse("kebab"), None);
}
#[cfg(feature = "script")]
#[test]
fn camel_names_come_from_the_registry() {
let pairs = registered_pairs();
assert_eq!(pairs.get("has_label").map(String::as_str), Some("hasLabel"));
assert_eq!(
pairs.get("from_string").map(String::as_str),
Some("fromString")
);
assert!(!pairs.contains_key("in_"));
}
}