Skip to main content

component_shape/
component_suffix.rs

1use heck::ToSnakeCase as _;
2
3/// Returns whether a component/prototyping suffix is a non-empty ASCII
4/// identifier suffix.
5pub 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/// Preferred generated component helper suffix.
34#[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}`")]
39/// Error returned when component suffix validation rejects a value.
40pub struct ComponentSuffixError {
41    value: String,
42}
43
44impl ComponentSuffixError {
45    /// Returns the invalid suffix value.
46    pub fn value(&self) -> &str {
47        &self.value
48    }
49}
50
51/// Returns whether a component/prototyping suffix is valid.
52pub const fn is_valid_component_suffix(value: &str) -> bool {
53    is_valid_ascii_identifier_suffix(value)
54}
55
56/// Validate a component/prototyping suffix.
57///
58/// # Errors
59///
60/// Returns [`ComponentSuffixError`] when `value` is empty, is `_`, starts with a
61/// non-identifier ASCII character, contains non-identifier ASCII characters, or
62/// contains non-ASCII characters.
63pub 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
73/// Derive a generated helper suffix from a field name and shape suffix source.
74///
75/// Both inputs are normalized to snake_case. If the suffix is the same as the
76/// field name, this returns `None` so callers can use their own fallback. If the
77/// suffix repeats the field name as a prefix, the duplicate prefix is removed:
78/// `("email", "email_input")` becomes `Some("input")`.
79pub 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    /// Creates a component suffix from static metadata.
97    ///
98    /// # Panics
99    ///
100    /// Panics when `value` is not a valid component suffix. Use
101    /// [`validate_component_suffix`] when the value comes from a recoverable
102    /// runtime input boundary.
103    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    /// Creates an optional component suffix from optional static metadata.
112    ///
113    /// # Panics
114    ///
115    /// Panics when `value` is `Some` and the contained string is not a valid
116    /// component suffix.
117    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    /// Returns the suffix text.
125    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}