Skip to main content

use_vue/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::fmt;
5use std::error::Error;
6use use_js_identifier::{JsIdentifier, JsIdentifierError};
7
8/// Validated Vue component name metadata.
9#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
10pub struct VueComponentName(String);
11
12impl VueComponentName {
13    /// Creates a lightly validated Vue component name.
14    ///
15    /// # Errors
16    ///
17    /// Returns [`VueNameError`] when `input` is empty, whitespace-bearing, or not component-shaped.
18    pub fn new(input: &str) -> Result<Self, VueNameError> {
19        let trimmed = input.trim();
20        if trimmed.is_empty() {
21            return Err(VueNameError::Empty);
22        }
23        if trimmed.chars().any(char::is_whitespace) {
24            return Err(VueNameError::ContainsWhitespace);
25        }
26        if trimmed.contains('-') {
27            if trimmed.split('-').any(str::is_empty) {
28                return Err(VueNameError::InvalidKebabName);
29            }
30            return Ok(Self(trimmed.to_string()));
31        }
32
33        let identifier = JsIdentifier::new(trimmed).map_err(VueNameError::Identifier)?;
34        if !identifier
35            .as_str()
36            .chars()
37            .next()
38            .is_some_and(|character| character.is_ascii_uppercase())
39        {
40            return Err(VueNameError::NotComponentName);
41        }
42        Ok(Self(identifier.as_str().to_string()))
43    }
44
45    /// Returns the component name.
46    #[must_use]
47    pub fn as_str(&self) -> &str {
48        &self.0
49    }
50}
51
52/// Vue file-kind metadata.
53#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
54pub enum VueFileKind {
55    Component,
56    Composable,
57    Store,
58    Page,
59    Layout,
60}
61
62/// Vue API style labels.
63#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
64pub enum VueApiStyle {
65    Options,
66    Composition,
67    ScriptSetup,
68}
69
70impl VueApiStyle {
71    /// Returns the API style label.
72    #[must_use]
73    pub const fn as_str(self) -> &'static str {
74        match self {
75            Self::Options => "options",
76            Self::Composition => "composition",
77            Self::ScriptSetup => "script-setup",
78        }
79    }
80}
81
82/// Validated Vue directive name metadata without the leading `v-`.
83#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
84pub struct VueDirectiveName(String);
85
86impl VueDirectiveName {
87    /// Creates a directive name, accepting an optional leading `v-`.
88    ///
89    /// # Errors
90    ///
91    /// Returns [`VueNameError`] when `input` is empty or contains whitespace.
92    pub fn new(input: &str) -> Result<Self, VueNameError> {
93        let trimmed_input = input.trim();
94        let trimmed = trimmed_input.strip_prefix("v-").unwrap_or(trimmed_input);
95        if trimmed.is_empty() {
96            return Err(VueNameError::Empty);
97        }
98        if trimmed.chars().any(char::is_whitespace) {
99            return Err(VueNameError::ContainsWhitespace);
100        }
101        Ok(Self(trimmed.to_string()))
102    }
103
104    /// Returns the directive name without `v-`.
105    #[must_use]
106    pub fn as_str(&self) -> &str {
107        &self.0
108    }
109}
110
111/// Error returned when Vue name metadata is invalid.
112#[derive(Clone, Debug, Eq, PartialEq)]
113pub enum VueNameError {
114    Empty,
115    ContainsWhitespace,
116    Identifier(JsIdentifierError),
117    NotComponentName,
118    InvalidKebabName,
119}
120
121impl fmt::Display for VueNameError {
122    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
123        match self {
124            Self::Empty => formatter.write_str("Vue metadata name cannot be empty"),
125            Self::ContainsWhitespace => {
126                formatter.write_str("Vue metadata name cannot contain whitespace")
127            }
128            Self::Identifier(error) => write!(formatter, "invalid JavaScript identifier: {error}"),
129            Self::NotComponentName => formatter
130                .write_str("Vue component name must be PascalCase-shaped or kebab-case-shaped"),
131            Self::InvalidKebabName => {
132                formatter.write_str("Vue kebab-case name contains an empty segment")
133            }
134        }
135    }
136}
137
138impl Error for VueNameError {
139    fn source(&self) -> Option<&(dyn Error + 'static)> {
140        match self {
141            Self::Identifier(error) => Some(error),
142            Self::Empty
143            | Self::ContainsWhitespace
144            | Self::NotComponentName
145            | Self::InvalidKebabName => None,
146        }
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::{VueApiStyle, VueComponentName, VueDirectiveName, VueNameError};
153
154    #[test]
155    fn validates_component_names() -> Result<(), VueNameError> {
156        assert_eq!(VueComponentName::new("UserCard")?.as_str(), "UserCard");
157        assert_eq!(VueComponentName::new("user-card")?.as_str(), "user-card");
158        assert_eq!(
159            VueComponentName::new("user--card"),
160            Err(VueNameError::InvalidKebabName)
161        );
162        Ok(())
163    }
164
165    #[test]
166    fn validates_directive_names() -> Result<(), VueNameError> {
167        let directive = VueDirectiveName::new("v-focus")?;
168        assert_eq!(directive.as_str(), "focus");
169        assert_eq!(VueApiStyle::ScriptSetup.as_str(), "script-setup");
170        Ok(())
171    }
172}