Skip to main content

use_angular/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6use use_js_identifier::{JsIdentifier, JsIdentifierError};
7
8/// Angular version-family labels.
9#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
10pub enum AngularVersionFamily {
11    Angular2,
12    Angular3,
13    Angular4,
14    Angular5,
15    Angular6,
16    Angular7,
17    Angular8,
18    Angular9,
19    Angular10,
20    Angular11,
21    Angular12,
22    Angular13,
23    Angular14,
24    Angular15,
25    Angular16,
26    Angular17,
27    Angular18,
28    Angular19,
29    Angular20,
30}
31
32impl AngularVersionFamily {
33    /// Returns the version-family label.
34    #[must_use]
35    pub const fn as_str(self) -> &'static str {
36        match self {
37            Self::Angular2 => "angular2",
38            Self::Angular3 => "angular3",
39            Self::Angular4 => "angular4",
40            Self::Angular5 => "angular5",
41            Self::Angular6 => "angular6",
42            Self::Angular7 => "angular7",
43            Self::Angular8 => "angular8",
44            Self::Angular9 => "angular9",
45            Self::Angular10 => "angular10",
46            Self::Angular11 => "angular11",
47            Self::Angular12 => "angular12",
48            Self::Angular13 => "angular13",
49            Self::Angular14 => "angular14",
50            Self::Angular15 => "angular15",
51            Self::Angular16 => "angular16",
52            Self::Angular17 => "angular17",
53            Self::Angular18 => "angular18",
54            Self::Angular19 => "angular19",
55            Self::Angular20 => "angular20",
56        }
57    }
58}
59
60impl fmt::Display for AngularVersionFamily {
61    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
62        formatter.write_str(self.as_str())
63    }
64}
65
66impl FromStr for AngularVersionFamily {
67    type Err = AngularNameError;
68
69    fn from_str(input: &str) -> Result<Self, Self::Err> {
70        match normalized_label(input)?.as_str() {
71            "angular2" | "2" => Ok(Self::Angular2),
72            "angular3" | "3" => Ok(Self::Angular3),
73            "angular4" | "4" => Ok(Self::Angular4),
74            "angular5" | "5" => Ok(Self::Angular5),
75            "angular6" | "6" => Ok(Self::Angular6),
76            "angular7" | "7" => Ok(Self::Angular7),
77            "angular8" | "8" => Ok(Self::Angular8),
78            "angular9" | "9" => Ok(Self::Angular9),
79            "angular10" | "10" => Ok(Self::Angular10),
80            "angular11" | "11" => Ok(Self::Angular11),
81            "angular12" | "12" => Ok(Self::Angular12),
82            "angular13" | "13" => Ok(Self::Angular13),
83            "angular14" | "14" => Ok(Self::Angular14),
84            "angular15" | "15" => Ok(Self::Angular15),
85            "angular16" | "16" => Ok(Self::Angular16),
86            "angular17" | "17" => Ok(Self::Angular17),
87            "angular18" | "18" => Ok(Self::Angular18),
88            "angular19" | "19" => Ok(Self::Angular19),
89            "angular20" | "20" => Ok(Self::Angular20),
90            _ => Err(AngularNameError::UnknownLabel),
91        }
92    }
93}
94
95/// Angular file-kind labels.
96#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
97pub enum AngularFileKind {
98    Component,
99    Template,
100    Stylesheet,
101    Spec,
102    Service,
103    Module,
104    RoutingModule,
105    Config,
106}
107
108impl AngularFileKind {
109    /// Returns the file-kind label.
110    #[must_use]
111    pub const fn as_str(self) -> &'static str {
112        match self {
113            Self::Component => "component",
114            Self::Template => "template",
115            Self::Stylesheet => "stylesheet",
116            Self::Spec => "spec",
117            Self::Service => "service",
118            Self::Module => "module",
119            Self::RoutingModule => "routing-module",
120            Self::Config => "config",
121        }
122    }
123}
124
125impl fmt::Display for AngularFileKind {
126    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
127        formatter.write_str(self.as_str())
128    }
129}
130
131impl FromStr for AngularFileKind {
132    type Err = AngularNameError;
133
134    fn from_str(input: &str) -> Result<Self, Self::Err> {
135        match normalized_label(input)?.as_str() {
136            "component" => Ok(Self::Component),
137            "template" => Ok(Self::Template),
138            "stylesheet" | "style" | "styles" => Ok(Self::Stylesheet),
139            "spec" | "test" => Ok(Self::Spec),
140            "service" => Ok(Self::Service),
141            "module" => Ok(Self::Module),
142            "routingmodule" | "routing" => Ok(Self::RoutingModule),
143            "config" => Ok(Self::Config),
144            _ => Err(AngularNameError::UnknownLabel),
145        }
146    }
147}
148
149/// Angular artifact-kind labels.
150#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
151pub enum AngularArtifactKind {
152    Component,
153    Directive,
154    Pipe,
155    Service,
156    Module,
157    Guard,
158    Resolver,
159    Interceptor,
160}
161
162impl AngularArtifactKind {
163    /// Returns the artifact-kind label.
164    #[must_use]
165    pub const fn as_str(self) -> &'static str {
166        match self {
167            Self::Component => "component",
168            Self::Directive => "directive",
169            Self::Pipe => "pipe",
170            Self::Service => "service",
171            Self::Module => "module",
172            Self::Guard => "guard",
173            Self::Resolver => "resolver",
174            Self::Interceptor => "interceptor",
175        }
176    }
177}
178
179impl fmt::Display for AngularArtifactKind {
180    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
181        formatter.write_str(self.as_str())
182    }
183}
184
185impl FromStr for AngularArtifactKind {
186    type Err = AngularNameError;
187
188    fn from_str(input: &str) -> Result<Self, Self::Err> {
189        match normalized_label(input)?.as_str() {
190            "component" => Ok(Self::Component),
191            "directive" => Ok(Self::Directive),
192            "pipe" => Ok(Self::Pipe),
193            "service" => Ok(Self::Service),
194            "module" => Ok(Self::Module),
195            "guard" => Ok(Self::Guard),
196            "resolver" => Ok(Self::Resolver),
197            "interceptor" => Ok(Self::Interceptor),
198            _ => Err(AngularNameError::UnknownLabel),
199        }
200    }
201}
202
203/// Angular standalone mode labels.
204#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
205pub enum AngularStandaloneMode {
206    Standalone,
207    NgModuleBased,
208}
209
210impl AngularStandaloneMode {
211    /// Returns the standalone mode label.
212    #[must_use]
213    pub const fn as_str(self) -> &'static str {
214        match self {
215            Self::Standalone => "standalone",
216            Self::NgModuleBased => "ng-module-based",
217        }
218    }
219}
220
221impl fmt::Display for AngularStandaloneMode {
222    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
223        formatter.write_str(self.as_str())
224    }
225}
226
227impl FromStr for AngularStandaloneMode {
228    type Err = AngularNameError;
229
230    fn from_str(input: &str) -> Result<Self, Self::Err> {
231        match normalized_label(input)?.as_str() {
232            "standalone" => Ok(Self::Standalone),
233            "ngmodulebased" | "ngmodule" | "modulebased" => Ok(Self::NgModuleBased),
234            _ => Err(AngularNameError::UnknownLabel),
235        }
236    }
237}
238
239/// Common Angular config file labels.
240#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
241pub enum AngularConfigFile {
242    AngularJson,
243    TsConfigAppJson,
244    TsConfigSpecJson,
245}
246
247impl AngularConfigFile {
248    /// Returns the config file label.
249    #[must_use]
250    pub const fn as_str(self) -> &'static str {
251        match self {
252            Self::AngularJson => "angular.json",
253            Self::TsConfigAppJson => "tsconfig.app.json",
254            Self::TsConfigSpecJson => "tsconfig.spec.json",
255        }
256    }
257}
258
259impl fmt::Display for AngularConfigFile {
260    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
261        formatter.write_str(self.as_str())
262    }
263}
264
265impl FromStr for AngularConfigFile {
266    type Err = AngularNameError;
267
268    fn from_str(input: &str) -> Result<Self, Self::Err> {
269        match normalized_label(input)?.as_str() {
270            "angularjson" | "angular.json" => Ok(Self::AngularJson),
271            "tsconfigappjson" | "tsconfig.app.json" => Ok(Self::TsConfigAppJson),
272            "tsconfigspecjson" | "tsconfig.spec.json" => Ok(Self::TsConfigSpecJson),
273            _ => Err(AngularNameError::UnknownLabel),
274        }
275    }
276}
277
278/// Validated Angular directive class name metadata.
279#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
280pub struct AngularDirectiveName(String);
281
282impl AngularDirectiveName {
283    /// Creates a `PascalCase` Angular directive class name.
284    ///
285    /// # Errors
286    ///
287    /// Returns [`AngularNameError`] when `input` is not an ASCII identifier or is not `PascalCase`-shaped.
288    pub fn new(input: &str) -> Result<Self, AngularNameError> {
289        validate_pascal_case(input).map(Self)
290    }
291
292    /// Returns the directive name.
293    #[must_use]
294    pub fn as_str(&self) -> &str {
295        &self.0
296    }
297}
298
299impl fmt::Display for AngularDirectiveName {
300    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
301        formatter.write_str(self.as_str())
302    }
303}
304
305impl FromStr for AngularDirectiveName {
306    type Err = AngularNameError;
307
308    fn from_str(input: &str) -> Result<Self, Self::Err> {
309        Self::new(input)
310    }
311}
312
313impl TryFrom<&str> for AngularDirectiveName {
314    type Error = AngularNameError;
315
316    fn try_from(value: &str) -> Result<Self, Self::Error> {
317        Self::new(value)
318    }
319}
320
321/// Validated Angular selector metadata.
322#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
323pub struct AngularSelector(String);
324
325impl AngularSelector {
326    /// Creates a lightly validated Angular selector.
327    ///
328    /// # Errors
329    ///
330    /// Returns [`AngularNameError`] when `input` is empty, contains whitespace, or is not selector-shaped.
331    pub fn new(input: &str) -> Result<Self, AngularNameError> {
332        let trimmed = input.trim();
333        if trimmed.is_empty() {
334            return Err(AngularNameError::Empty);
335        }
336        if trimmed.chars().any(char::is_whitespace) {
337            return Err(AngularNameError::ContainsWhitespace);
338        }
339        if !is_selector_shape(trimmed) {
340            return Err(AngularNameError::InvalidSelector);
341        }
342        Ok(Self(trimmed.to_string()))
343    }
344
345    /// Returns the selector.
346    #[must_use]
347    pub fn as_str(&self) -> &str {
348        &self.0
349    }
350}
351
352impl fmt::Display for AngularSelector {
353    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
354        formatter.write_str(self.as_str())
355    }
356}
357
358impl FromStr for AngularSelector {
359    type Err = AngularNameError;
360
361    fn from_str(input: &str) -> Result<Self, Self::Err> {
362        Self::new(input)
363    }
364}
365
366impl TryFrom<&str> for AngularSelector {
367    type Error = AngularNameError;
368
369    fn try_from(value: &str) -> Result<Self, Self::Error> {
370        Self::new(value)
371    }
372}
373
374/// Validated Angular module name metadata.
375#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
376pub struct AngularModuleName(String);
377
378impl AngularModuleName {
379    /// Creates Angular module name metadata.
380    ///
381    /// # Errors
382    ///
383    /// Returns [`AngularNameError`] when `input` is empty or contains whitespace.
384    pub fn new(input: &str) -> Result<Self, AngularNameError> {
385        let trimmed = input.trim();
386        if trimmed.is_empty() {
387            return Err(AngularNameError::Empty);
388        }
389        if trimmed.chars().any(char::is_whitespace) {
390            return Err(AngularNameError::ContainsWhitespace);
391        }
392        Ok(Self(trimmed.to_string()))
393    }
394
395    /// Returns the module name.
396    #[must_use]
397    pub fn as_str(&self) -> &str {
398        &self.0
399    }
400}
401
402impl fmt::Display for AngularModuleName {
403    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
404        formatter.write_str(self.as_str())
405    }
406}
407
408impl FromStr for AngularModuleName {
409    type Err = AngularNameError;
410
411    fn from_str(input: &str) -> Result<Self, Self::Err> {
412        Self::new(input)
413    }
414}
415
416impl TryFrom<&str> for AngularModuleName {
417    type Error = AngularNameError;
418
419    fn try_from(value: &str) -> Result<Self, Self::Error> {
420        Self::new(value)
421    }
422}
423
424/// Error returned when Angular metadata is invalid.
425#[derive(Clone, Debug, Eq, PartialEq)]
426pub enum AngularNameError {
427    Empty,
428    ContainsWhitespace,
429    Identifier(JsIdentifierError),
430    NotPascalCase,
431    InvalidSelector,
432    UnknownLabel,
433}
434
435impl fmt::Display for AngularNameError {
436    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
437        match self {
438            Self::Empty => formatter.write_str("Angular metadata text cannot be empty"),
439            Self::ContainsWhitespace => {
440                formatter.write_str("Angular metadata text cannot contain whitespace")
441            }
442            Self::Identifier(error) => write!(formatter, "{error}"),
443            Self::NotPascalCase => {
444                formatter.write_str("Angular artifact name must be `PascalCase`-shaped")
445            }
446            Self::InvalidSelector => formatter.write_str("invalid Angular selector"),
447            Self::UnknownLabel => formatter.write_str("unknown Angular metadata label"),
448        }
449    }
450}
451
452impl Error for AngularNameError {
453    fn source(&self) -> Option<&(dyn Error + 'static)> {
454        match self {
455            Self::Identifier(error) => Some(error),
456            Self::Empty
457            | Self::ContainsWhitespace
458            | Self::NotPascalCase
459            | Self::InvalidSelector
460            | Self::UnknownLabel => None,
461        }
462    }
463}
464
465fn validate_pascal_case(input: &str) -> Result<String, AngularNameError> {
466    let identifier = JsIdentifier::new(input).map_err(AngularNameError::Identifier)?;
467    if !identifier
468        .as_str()
469        .chars()
470        .next()
471        .is_some_and(|character| character.is_ascii_uppercase())
472    {
473        return Err(AngularNameError::NotPascalCase);
474    }
475    Ok(identifier.as_str().to_string())
476}
477
478fn is_selector_shape(input: &str) -> bool {
479    if let Some(inner) = input
480        .strip_prefix('[')
481        .and_then(|value| value.strip_suffix(']'))
482    {
483        return !inner.is_empty() && inner.chars().all(is_selector_character);
484    }
485    if let Some(class_selector) = input.strip_prefix('.') {
486        return !class_selector.is_empty() && class_selector.chars().all(is_selector_character);
487    }
488    !input.is_empty() && input.chars().all(is_selector_character)
489}
490
491const fn is_selector_character(character: char) -> bool {
492    character.is_ascii_alphanumeric() || matches!(character, '-' | '_')
493}
494
495fn normalized_label(input: &str) -> Result<String, AngularNameError> {
496    let trimmed = input.trim();
497    if trimmed.is_empty() {
498        return Err(AngularNameError::Empty);
499    }
500    Ok(trimmed
501        .chars()
502        .filter(|character| !matches!(character, '-' | '_' | ' '))
503        .flat_map(char::to_lowercase)
504        .collect())
505}
506
507#[cfg(test)]
508mod tests {
509    use super::{
510        AngularArtifactKind, AngularConfigFile, AngularDirectiveName, AngularFileKind,
511        AngularModuleName, AngularNameError, AngularSelector, AngularStandaloneMode,
512        AngularVersionFamily,
513    };
514    use use_js_identifier::JsIdentifierError;
515
516    #[test]
517    fn validates_selectors() -> Result<(), AngularNameError> {
518        assert_eq!(AngularSelector::new("app-root")?.as_str(), "app-root");
519        assert_eq!(
520            AngularSelector::new("[appHighlight]")?.as_str(),
521            "[appHighlight]"
522        );
523        assert_eq!(AngularSelector::new(".app-card")?.as_str(), ".app-card");
524        assert_eq!(AngularSelector::new(""), Err(AngularNameError::Empty));
525        assert_eq!(
526            AngularSelector::new("app root"),
527            Err(AngularNameError::ContainsWhitespace)
528        );
529        assert_eq!(
530            AngularSelector::new("#app"),
531            Err(AngularNameError::InvalidSelector)
532        );
533        Ok(())
534    }
535
536    #[test]
537    fn validates_names() -> Result<(), AngularNameError> {
538        assert_eq!(
539            AngularDirectiveName::new("AppHighlight")?.as_str(),
540            "AppHighlight"
541        );
542        assert_eq!(
543            AngularModuleName::new("FeatureModule")?.as_str(),
544            "FeatureModule"
545        );
546        assert_eq!(
547            AngularDirectiveName::new("appHighlight"),
548            Err(AngularNameError::NotPascalCase)
549        );
550        assert_eq!(
551            AngularDirectiveName::new("1App"),
552            Err(AngularNameError::Identifier(
553                JsIdentifierError::InvalidStart { character: '1' }
554            ))
555        );
556        assert_eq!(
557            AngularModuleName::new("Feature Module"),
558            Err(AngularNameError::ContainsWhitespace)
559        );
560        Ok(())
561    }
562
563    #[test]
564    fn parses_labels() -> Result<(), AngularNameError> {
565        assert_eq!(
566            "angular17".parse::<AngularVersionFamily>()?,
567            AngularVersionFamily::Angular17
568        );
569        assert_eq!(
570            "routing-module".parse::<AngularFileKind>()?,
571            AngularFileKind::RoutingModule
572        );
573        assert_eq!(
574            "component".parse::<AngularArtifactKind>()?,
575            AngularArtifactKind::Component
576        );
577        assert_eq!(
578            "ng-module".parse::<AngularStandaloneMode>()?,
579            AngularStandaloneMode::NgModuleBased
580        );
581        assert_eq!(
582            "angular.json".parse::<AngularConfigFile>()?,
583            AngularConfigFile::AngularJson
584        );
585        assert_eq!(AngularArtifactKind::Service.to_string(), "service");
586        Ok(())
587    }
588}