component_shape/
component_suffix.rs1use heck::ToSnakeCase as _;
2
3pub const fn is_valid_ascii_identifier_suffix(value: &str) -> bool {
6 let bytes = value.as_bytes();
7 if bytes.is_empty() || (bytes.len() == 1 && bytes[0] == b'_') {
8 return false;
9 }
10 if !is_ascii_ident_start(bytes[0]) {
11 return false;
12 }
13
14 let mut idx = 1;
15 while idx < bytes.len() {
16 if !is_ascii_ident_continue(bytes[idx]) {
17 return false;
18 }
19 idx += 1;
20 }
21
22 true
23}
24
25const fn is_ascii_ident_start(byte: u8) -> bool {
26 byte == b'_' || byte.is_ascii_alphabetic()
27}
28
29const fn is_ascii_ident_continue(byte: u8) -> bool {
30 is_ascii_ident_start(byte) || byte.is_ascii_digit()
31}
32
33#[derive(Clone, Copy, Debug, Eq, PartialEq)]
35pub struct ComponentSuffix(&'static str);
36
37#[derive(Clone, Debug, Eq, thiserror::Error, PartialEq)]
38#[error("`field_suffix` must be a non-empty ASCII identifier suffix, got `{value}`")]
39pub struct ComponentSuffixError {
41 value: String,
42}
43
44impl ComponentSuffixError {
45 pub fn value(&self) -> &str {
47 &self.value
48 }
49}
50
51pub const fn is_valid_component_suffix(value: &str) -> bool {
53 is_valid_ascii_identifier_suffix(value)
54}
55
56pub fn validate_component_suffix(value: &str) -> Result<(), ComponentSuffixError> {
64 if is_valid_component_suffix(value) {
65 Ok(())
66 } else {
67 Err(ComponentSuffixError {
68 value: value.to_string(),
69 })
70 }
71}
72
73pub fn component_suffix_from_suffix(field_name: &str, suffix: &str) -> Option<String> {
80 let mut suffix = suffix.to_snake_case();
81 let field_name = field_name.to_snake_case();
82
83 if suffix == field_name {
84 return None;
85 }
86
87 let field_prefix = format!("{field_name}_");
88 if let Some(rest) = suffix.strip_prefix(&field_prefix) {
89 suffix = rest.to_string();
90 }
91
92 (!suffix.is_empty()).then_some(suffix)
93}
94
95impl ComponentSuffix {
96 pub const fn new(value: &'static str) -> Self {
104 assert!(
105 is_valid_component_suffix(value),
106 "component suffix must be a non-empty ASCII identifier suffix"
107 );
108 Self(value)
109 }
110
111 pub const fn new_opt(value: Option<&'static str>) -> Option<Self> {
118 match value {
119 Some(value) => Some(Self::new(value)),
120 None => None,
121 }
122 }
123
124 pub const fn as_str(self) -> &'static str {
126 self.0
127 }
128}
129
130#[cfg(test)]
131mod tests {
132 use super::{
133 ComponentSuffix, component_suffix_from_suffix, is_valid_component_suffix,
134 validate_component_suffix,
135 };
136
137 #[test]
138 fn component_suffix_validation_accepts_identifier_suffixes() {
139 assert!(is_valid_component_suffix("input"));
140 assert!(is_valid_component_suffix("number_input"));
141 assert!(is_valid_component_suffix("_internal"));
142 assert!(is_valid_component_suffix("input2"));
143 }
144
145 #[test]
146 fn component_suffix_validation_rejects_invalid_suffixes() {
147 for value in ["", "_", "2input", "input-field", "input field", "入力"] {
148 let error = validate_component_suffix(value)
149 .expect_err("invalid component suffix should be rejected");
150 assert_eq!(error.value(), value);
151 }
152 }
153
154 #[test]
155 fn component_suffix_new_stores_valid_suffixes() {
156 assert_eq!(ComponentSuffix::new("input").as_str(), "input");
157 assert_eq!(
158 ComponentSuffix::new_opt(Some("select")).map(ComponentSuffix::as_str),
159 Some("select")
160 );
161 assert_eq!(ComponentSuffix::new_opt(None), None);
162 }
163
164 #[test]
165 #[should_panic(expected = "component suffix must be a non-empty ASCII identifier suffix")]
166 fn component_suffix_new_rejects_invalid_suffixes() {
167 let _ = ComponentSuffix::new("input-field");
168 }
169
170 #[test]
171 fn component_suffix_from_suffix_normalizes_suffixes() {
172 assert_eq!(
173 component_suffix_from_suffix("country", "CountrySelect"),
174 Some("select".to_string())
175 );
176 assert_eq!(
177 component_suffix_from_suffix("email", "email_input"),
178 Some("input".to_string())
179 );
180 assert_eq!(component_suffix_from_suffix("tags", "tags"), None);
181 }
182}