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#[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 #[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#[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 #[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#[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 #[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#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
205pub enum AngularStandaloneMode {
206 Standalone,
207 NgModuleBased,
208}
209
210impl AngularStandaloneMode {
211 #[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#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
241pub enum AngularConfigFile {
242 AngularJson,
243 TsConfigAppJson,
244 TsConfigSpecJson,
245}
246
247impl AngularConfigFile {
248 #[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#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
280pub struct AngularDirectiveName(String);
281
282impl AngularDirectiveName {
283 pub fn new(input: &str) -> Result<Self, AngularNameError> {
289 validate_pascal_case(input).map(Self)
290 }
291
292 #[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#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
323pub struct AngularSelector(String);
324
325impl AngularSelector {
326 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 #[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#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
376pub struct AngularModuleName(String);
377
378impl AngularModuleName {
379 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 #[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#[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}