snake_case/
lib.rs

1#![allow(clippy::manual_range_contains)]
2
3use std::{convert::TryFrom, fmt};
4
5#[cfg(feature = "serde")]
6use serde::{Deserialize, Deserializer, Serialize};
7
8// ----------------------------------------------------------------------------
9
10/// Is the given string a non-empty snake_case string?
11/// In particular, does it match  ^[_a-z][_a-z0-9]*$  ?
12pub const fn is_snake_case(string: &str) -> bool {
13    // we only care about ascii chars, which fit in a byte.
14    // iterating over utf8 continuation bytes and the like will not count as valid snake case anyway.
15    let (len, bytes) = (string.len(), string.as_bytes());
16    const fn valid_start(b: u8) -> bool {
17        b == b'_' || b'a' <= b && b <= b'z'
18    };
19    const fn is_snake_case_character(c: u8) -> bool {
20        b'a' <= c && c <= b'z' || b'0' <= c && c <= b'9' || c == b'_'
21    }
22    // non-empty and starts with a..z or _
23    if bytes.is_empty() || !valid_start(bytes[0]) {
24        return false;
25    }
26    //check the rest
27    let mut i = 1; // we already checked the first byte, its fine
28    loop {
29        if i >= len - 1 {
30            break true;
31        }
32        if !is_snake_case_character(bytes[i]) {
33            break false;
34        }
35        i += 1;
36    }
37}
38
39// ----------------------------------------------------------------------------
40
41/// Only one possible error: the given string was not valid snake_case.
42#[derive(Clone, Debug)]
43pub struct InvalidSnakeCase;
44
45// ----------------------------------------------------------------------------
46
47/// An owning string type that can only contain valid snake_case.
48/// In other words, it always matches  ^[_a-z][_a-z0-9]*$
49/// * Non-empty
50/// * Starts with a lower case ASCII letter or underscore
51/// * Contains only lower case ASCII letters, underscores and digits
52#[derive(Clone, Eq, Hash, Ord, PartialEq, PartialOrd)]
53#[cfg_attr(feature = "serde", derive(Serialize))]
54pub struct SnakeCase(String);
55
56impl SnakeCase {
57    pub fn try_from_str(s: &str) -> Result<SnakeCase, InvalidSnakeCase> {
58        if is_snake_case(s) {
59            Ok(SnakeCase(s.to_string()))
60        } else {
61            Err(InvalidSnakeCase)
62        }
63    }
64
65    pub fn try_from_string(s: String) -> Result<SnakeCase, InvalidSnakeCase> {
66        if is_snake_case(&s) {
67            Ok(SnakeCase(s))
68        } else {
69            Err(InvalidSnakeCase)
70        }
71    }
72
73    pub fn as_str(&self) -> &str {
74        &self.0
75    }
76
77    pub fn as_ref(&self) -> SnakeCaseRef {
78        SnakeCaseRef(&self.0)
79    }
80}
81
82impl TryFrom<&str> for SnakeCase {
83    type Error = InvalidSnakeCase;
84
85    fn try_from(s: &str) -> Result<Self, Self::Error> {
86        SnakeCase::try_from_str(s)
87    }
88}
89
90impl TryFrom<String> for SnakeCase {
91    type Error = InvalidSnakeCase;
92
93    fn try_from(s: String) -> Result<Self, Self::Error> {
94        SnakeCase::try_from_string(s)
95    }
96}
97
98impl std::borrow::Borrow<str> for SnakeCase {
99    fn borrow(&self) -> &str {
100        &self.0
101    }
102}
103
104impl fmt::Debug for SnakeCase {
105    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
106        self.as_str().fmt(f)
107    }
108}
109
110impl fmt::Display for SnakeCase {
111    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
112        self.as_str().fmt(f)
113    }
114}
115
116#[cfg(feature = "serde")]
117impl<'de> Deserialize<'de> for SnakeCase {
118    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
119    where
120        D: Deserializer<'de>,
121    {
122        let string = String::deserialize(deserializer)?;
123        SnakeCase::try_from_str(&string).map_err(|_: InvalidSnakeCase| {
124            serde::de::Error::custom(format!("Expected snake_case, got '{}'", string))
125        })
126    }
127}
128
129impl std::cmp::PartialEq<SnakeCase> for &str {
130    fn eq(&self, other: &SnakeCase) -> bool {
131        *self == other.as_str()
132    }
133}
134
135impl std::cmp::PartialEq<str> for SnakeCase {
136    fn eq(&self, other: &str) -> bool {
137        self.as_str() == other
138    }
139}
140
141impl std::cmp::PartialEq<&str> for SnakeCase {
142    fn eq(&self, other: &&str) -> bool {
143        self.as_str() == *other
144    }
145}
146
147impl std::cmp::PartialEq<String> for SnakeCase {
148    fn eq(&self, other: &String) -> bool {
149        self.as_str() == *other
150    }
151}
152
153// ----------------------------------------------------------------------------
154
155/// An non-owning string type that can only refer to string containing valid snake_case.
156/// In other words, it always matches  ^[_a-z][_a-z0-9]*$
157/// * Non-empty
158/// * Starts with a lower case ASCII letter or underscore
159/// * Contains only lower case ASCII letters, underscores and digits
160#[derive(Copy, Clone, Eq, Hash, Ord, PartialEq, PartialOrd)]
161#[cfg_attr(feature = "serde", derive(Serialize))]
162pub struct SnakeCaseRef<'a>(&'a str);
163
164impl<'a> SnakeCaseRef<'a> {
165    pub const fn try_from_str(s: &str) -> Result<SnakeCaseRef, InvalidSnakeCase> {
166        if is_snake_case(s) {
167            Ok(SnakeCaseRef(s))
168        } else {
169            Err(InvalidSnakeCase)
170        }
171    }
172
173    pub const fn as_str(&self) -> &'a str {
174        self.0
175    }
176
177    pub fn to_owned(&self) -> SnakeCase {
178        SnakeCase(self.0.to_string())
179    }
180}
181
182impl<'a> TryFrom<&'a str> for SnakeCaseRef<'a> {
183    type Error = InvalidSnakeCase;
184
185    fn try_from(s: &'a str) -> Result<Self, Self::Error> {
186        SnakeCaseRef::try_from_str(s)
187    }
188}
189
190impl std::borrow::Borrow<str> for SnakeCaseRef<'_> {
191    fn borrow(&self) -> &str {
192        &self.0
193    }
194}
195
196impl fmt::Debug for SnakeCaseRef<'_> {
197    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
198        self.as_str().fmt(f)
199    }
200}
201
202impl fmt::Display for SnakeCaseRef<'_> {
203    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
204        self.as_str().fmt(f)
205    }
206}
207
208impl std::cmp::PartialEq<SnakeCaseRef<'_>> for str {
209    fn eq(&self, other: &SnakeCaseRef<'_>) -> bool {
210        self == other.0
211    }
212}
213
214impl std::cmp::PartialEq<SnakeCaseRef<'_>> for &str {
215    fn eq(&self, other: &SnakeCaseRef<'_>) -> bool {
216        *self == other.0
217    }
218}
219
220impl std::cmp::PartialEq<str> for SnakeCaseRef<'_> {
221    fn eq(&self, other: &str) -> bool {
222        self.as_str() == other
223    }
224}
225
226impl std::cmp::PartialEq<&str> for SnakeCaseRef<'_> {
227    fn eq(&self, other: &&str) -> bool {
228        self.as_str() == *other
229    }
230}
231
232impl std::cmp::PartialEq<String> for SnakeCaseRef<'_> {
233    fn eq(&self, other: &String) -> bool {
234        self.as_str() == *other
235    }
236}
237
238// ----------------------------------------------------------------------------
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243
244    #[test]
245    fn snake_case() {
246        assert_eq!(SnakeCase::try_from_str("_hello42").unwrap(), "_hello42");
247        assert_eq!(
248            SnakeCase::try_from_str("_hello42").unwrap(),
249            "_hello42".to_string()
250        );
251        assert_eq!("_hello42", SnakeCase::try_from_str("_hello42").unwrap());
252        assert!(SnakeCase::try_from_str("").is_err());
253        assert!(SnakeCase::try_from_str("42").is_err());
254        assert!(SnakeCase::try_from_str("_").is_ok());
255    }
256
257    #[test]
258    fn snake_case_ref() {
259        assert_eq!(SnakeCaseRef::try_from_str("_hello42").unwrap(), "_hello42");
260        assert_eq!(
261            SnakeCaseRef::try_from_str("_hello42").unwrap(),
262            "_hello42".to_string()
263        );
264        assert_eq!("_hello42", SnakeCaseRef::try_from_str("_hello42").unwrap());
265        assert!(SnakeCaseRef::try_from_str("").is_err());
266        assert!(SnakeCaseRef::try_from_str("42").is_err());
267        assert!(SnakeCaseRef::try_from_str("_").is_ok());
268    }
269
270    #[test]
271    fn snake_case_conversions() {
272        let sc = SnakeCase::try_from_str("hello_world").unwrap();
273        let scr: SnakeCaseRef = sc.as_ref();
274        assert_eq!(scr, "hello_world");
275        let sc2: SnakeCase = scr.to_owned();
276        assert_eq!(sc2, "hello_world");
277
278        use std::collections::HashSet;
279        let mut set: HashSet<SnakeCase> = HashSet::new();
280        set.insert(SnakeCase::try_from_str("hello_world").unwrap());
281        assert!(set.contains(SnakeCaseRef::try_from_str("hello_world").unwrap().as_str()));
282    }
283}