use bock_lexer::keyword_lookup;
use thiserror::Error;
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum RenameError {
#[error("`{0}` is not a valid Bock identifier")]
InvalidIdentifier(String),
#[error("`{0}` is a reserved Bock keyword and cannot be used as a name")]
ReservedKeyword(String),
#[error(
"`{new}` does not match the capitalization of `{old}`: names starting \
with an uppercase letter are type identifiers, all others are value \
identifiers"
)]
CapitalizationMismatch {
old: String,
new: String,
},
}
pub fn validate_new_name(old_name: &str, new_name: &str) -> Result<(), RenameError> {
if !is_identifier_shaped(new_name) {
return Err(RenameError::InvalidIdentifier(new_name.to_string()));
}
if keyword_lookup(new_name).is_some() {
return Err(RenameError::ReservedKeyword(new_name.to_string()));
}
if starts_uppercase(old_name) != starts_uppercase(new_name) {
return Err(RenameError::CapitalizationMismatch {
old: old_name.to_string(),
new: new_name.to_string(),
});
}
Ok(())
}
fn is_identifier_shaped(name: &str) -> bool {
if name == "_" {
return false;
}
let mut chars = name.chars();
let Some(first) = chars.next() else {
return false;
};
if !(first.is_alphabetic() || first == '_') {
return false;
}
chars.all(|c| c.is_alphanumeric() || c == '_')
}
fn starts_uppercase(name: &str) -> bool {
name.starts_with(char::is_uppercase)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn accepts_simple_lowercase_rename() {
assert_eq!(validate_new_name("answer", "result"), Ok(()));
}
#[test]
fn accepts_underscores_and_digits() {
assert_eq!(validate_new_name("answer", "the_answer_42"), Ok(()));
assert_eq!(validate_new_name("answer", "_private"), Ok(()));
}
#[test]
fn accepts_uppercase_rename_for_uppercase_name() {
assert_eq!(validate_new_name("Point", "Coordinate"), Ok(()));
}
#[test]
fn rejects_keywords() {
for kw in ["fn", "let", "mut", "match", "if", "true", "false"] {
assert_eq!(
validate_new_name("answer", kw),
Err(RenameError::ReservedKeyword(kw.to_string())),
"`{kw}` must be rejected",
);
}
}
#[test]
fn rejects_constructor_keywords() {
for kw in ["Ok", "Err", "Some", "None", "Self"] {
assert_eq!(
validate_new_name("Point", kw),
Err(RenameError::ReservedKeyword(kw.to_string())),
"`{kw}` must be rejected",
);
}
}
#[test]
fn rejects_non_identifier_shapes() {
for bad in ["", "_", "123abc", "foo-bar", "foo bar", "a.b", "x!"] {
assert_eq!(
validate_new_name("answer", bad),
Err(RenameError::InvalidIdentifier(bad.to_string())),
"`{bad}` must be rejected",
);
}
}
#[test]
fn rejects_capitalization_class_change() {
assert!(matches!(
validate_new_name("Point", "point"),
Err(RenameError::CapitalizationMismatch { .. }),
));
assert!(matches!(
validate_new_name("answer", "Answer"),
Err(RenameError::CapitalizationMismatch { .. }),
));
}
#[test]
fn error_messages_are_descriptive() {
let err = validate_new_name("answer", "fn").expect_err("keyword rejected");
assert!(err.to_string().contains("reserved"), "got: {err}");
let err = validate_new_name("Point", "point").expect_err("case rejected");
assert!(err.to_string().contains("capitalization"), "got: {err}");
}
}