use smol_str::SmolStr;
use std::{
borrow::{Borrow, Cow},
fmt,
str::FromStr,
};
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Canonical(SmolStr);
pub const MAX_CANONICAL_TOKEN_LEN: usize = 256;
impl Canonical {
pub fn try_new(input: &str) -> Result<Self, CanonicalError> {
let token = canonicalize_bounded(input)?;
if token.is_empty() {
return Err(CanonicalError::InvalidCanonicalToken {
value: input.to_string(),
});
}
Ok(Self(SmolStr::new(token.as_ref())))
}
#[inline]
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
#[inline]
#[must_use]
pub fn into_inner(self) -> String {
self.0.to_string()
}
}
fn canonicalize_bounded(input: &str) -> Result<Cow<'_, str>, CanonicalError> {
if is_canonical(input) {
if input.len() > MAX_CANONICAL_TOKEN_LEN {
return Err(CanonicalError::CanonicalTokenTooLong {
max_len: MAX_CANONICAL_TOKEN_LEN,
});
}
return Ok(Cow::Borrowed(input));
}
let mut out = String::with_capacity(input.len().min(MAX_CANONICAL_TOKEN_LEN));
let mut pending_sep = false;
for ch in input.chars() {
let c = ch.to_ascii_uppercase();
if c.is_ascii_alphanumeric() {
let sep_len = usize::from(pending_sep);
if out.len() + sep_len + 1 > MAX_CANONICAL_TOKEN_LEN {
return Err(CanonicalError::CanonicalTokenTooLong {
max_len: MAX_CANONICAL_TOKEN_LEN,
});
}
if pending_sep {
out.push('_');
}
out.push(c);
pending_sep = false;
} else if !out.is_empty() {
pending_sep = true;
}
}
Ok(Cow::Owned(out))
}
impl fmt::Display for Canonical {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_ref())
}
}
impl AsRef<str> for Canonical {
fn as_ref(&self) -> &str {
self.0.as_ref()
}
}
impl Borrow<str> for Canonical {
fn borrow(&self) -> &str {
self.as_ref()
}
}
impl FromStr for Canonical {
type Err = CanonicalError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::try_new(s)
}
}
#[inline]
#[must_use]
pub fn canonicalize(input: &str) -> Cow<'_, str> {
if is_canonical(input) {
return Cow::Borrowed(input);
}
let mut out = String::with_capacity(input.len());
let mut prev_sep = true;
for ch in input.chars() {
let c = ch.to_ascii_uppercase();
if c.is_ascii_alphanumeric() {
out.push(c);
prev_sep = false;
} else if !prev_sep {
out.push('_');
prev_sep = true;
}
}
if out.ends_with('_') {
out.pop(); }
Cow::Owned(out)
}
#[inline]
#[must_use]
pub fn has_canonical_token_boundaries(input: &str) -> bool {
let trimmed = input.trim();
let mut chars = trimmed.chars();
let Some(first) = chars.next() else {
return false;
};
let last = chars.next_back().unwrap_or(first);
first.is_ascii_alphanumeric() && last.is_ascii_alphanumeric()
}
#[inline]
fn is_canonical(input: &str) -> bool {
let b = input.as_bytes();
if b.is_empty() || b[0] == b'_' || b[b.len() - 1] == b'_' {
return false;
}
let mut prev = b'_';
for &c in b {
match c {
b'A'..=b'Z' | b'0'..=b'9' => prev = c,
b'_' if prev != b'_' => prev = c,
_ => return false,
}
}
true
}
pub trait StringCode {
fn code(&self) -> &str;
fn is_canonical(&self) -> bool {
true
}
}
#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum CanonicalError {
#[error("Invalid canonical token: '{value}' - canonicalized value must be non-empty")]
InvalidCanonicalToken {
value: String,
},
#[error("canonical token exceeds maximum length of {max_len} bytes")]
CanonicalTokenTooLong {
max_len: usize,
},
}